The form backend API, documented in plain English.
One endpoint. Standard HTTP. JSON, urlencoded, or multipart — your pick. No SDK to install, no webhook proxy to configure, no handshake. POST a form, get an email and a dashboard row. This page is the entire integration spec for POST /api/submit, plus the read API, webhooks, error codes, and rate limits.
Free · No credit card · 1,000/mo
The submit endpoint is a single HTTP POST. Send your form payload, including the access_key that identifies the form, and splitforms emails the submission to the form's configured recipient and writes a row to your submissions table. There is no version pinning required — v1 is stable and additive only.
Authentication
Authentication for the submit endpoint is a single string field named access_key. Each splitforms form gets its own access key — it identifies which form, inbox, and dashboard a submission belongs to. Because the key lives in client-side HTML, it is not a secret in the bearer-token sense; if you want to stop other sites from reusing it, set an allowed-domain list on the form in your dashboard.
<form action="https://splitforms.com/api/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<input type="email" name="email" required />
<textarea name="message" required></textarea>
<button type="submit">Send</button>
</form>Read API authentication
For the read API (GET /api/submissions), generate a personal access token under Dashboard → API and send it as a bearer token. Personal access tokens scope to your account and never appear in client-side code.
curl https://splitforms.com/api/submissions \
-H "Authorization: Bearer YOUR_PERSONAL_ACCESS_TOKEN"Request
Request headers
| Header | Required | Description |
|---|---|---|
| Content-Type | Recommended | application/json, application/x-www-form-urlencoded, or multipart/form-data. If omitted, body is parsed as urlencoded. |
| Accept | Optional | Set to application/json to force a JSON response (otherwise we may render a redirect or success HTML). |
| Origin / Referer | Optional | Used for allowed-domain checking when the form has an allowlist set. CORS is open: any origin may POST. |
Required body field
| Field | Type | Description |
|---|---|---|
| access_key | string | Your splitforms access key. Identifies the form/inbox the submission belongs to. Required on every request. |
Reserved optional body fields
These field names are reserved— they control submission behavior and are stripped from the saved submission data so they don't appear in the email body or dashboard table.
| Field | Type | Description |
|---|---|---|
| botcheck | string | Honeypot. If non-empty the request is silently accepted (200) but the row is flagged is_spam=true and no email/webhook fires. |
| redirect | URL | Absolute URL to redirect to on success (HTTP 302). If omitted and the request asks for JSON, the API returns JSON instead. |
| subject | string | Override the email subject line for this submission. Falls back to the form's subject_template, then to "New form submission". |
| from_name | string | Override the From display name on the notification email. |
| replyto | Set the Reply-To header so hitting Reply replies to the visitor. If omitted, falls back to the email field if one exists. | |
| g-recaptcha-response | string | Standard reCAPTCHA token field name. Stripped from saved data; render a reCAPTCHA widget client-side if you want one. |
| cf-turnstile-response | string | Standard Cloudflare Turnstile token field name. Stripped from saved data. |
Everything else (your data)
Any other field you POST — name, company, budget, custom selects, hidden tracking params — is included verbatim in the email body and stored on the submission row. There is no schema to declare. Add a field to your form, ship it, and the next submission will have it.
Body limits
- Maximum field count: 100. Extra fields are dropped silently.
- Maximum value length per field: 10,000 bytes. Longer values are truncated.
- File uploads via multipart are accepted but not currently stored — only string field values are persisted.
Response
What you get back depends on the request shape. JSON requests get JSON. Native HTML form posts with a configured redirect get a 302. Native HTML form posts without a redirect get a built-in success page.
Successful submission (JSON)
Returned when Content-Type or Accept includes application/json.
HTTP/1.1 200 OK
Content-Type: application/json
{
"success": true,
"message": "Submission received"
}Successful submission (redirect)
Returned when a redirect field is present (or a per-form default redirect is configured) and the request did not ask for JSON.
HTTP/1.1 302 Found
Location: https://yoursite.com/thanksSuccessful submission (default HTML)
Returned for native HTML form submits with no redirect configured — a small built-in "Submission received" page so the user sees confirmation.
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
<!doctype html>… built-in success page …Validation error
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"success": false,
"message": "Missing access_key"
}Invalid access key
HTTP/1.1 404 Not Found
Content-Type: application/json
{
"success": false,
"message": "Invalid access_key"
}Origin not allowed
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"success": false,
"message": "Origin not allowed"
}Rate limited
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 60
{
"success": false,
"message": "Too many submissions — limit is 60/min per form. Try again in a moment."
}Monthly quota exhausted
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
{
"success": false,
"message": "Monthly submission limit reached (1000 on the Free plan). Resets June 1. Upgrade at https://splitforms.com/#pricing",
"quota": { "limit": 1000, "used": 1000, "plan": "free" }
}Error codes
Every error response is JSON with { success: false, message: "…" }. The HTTP status is the canonical signal — branch on it, not on the message string.
| Status | Meaning | When it fires | How to fix |
|---|---|---|---|
| 400 | Bad request | Body could not be parsed, JSON wasn't an object, or access_key was missing/empty. | Check your Content-Type matches the body. Confirm access_key is present at the top level. |
| 403 | Forbidden | Request Origin is not on the form's allowed-domain list, or the form is paused. | Add the domain in the dashboard, or unpause the form. |
| 404 | Invalid access_key | No form on any account matches the supplied access_key. | Re-copy the key from the dashboard. Watch for trailing whitespace. |
| 405 | Method not allowed | You sent GET, PUT, DELETE, etc. The endpoint accepts only POST and OPTIONS. | Switch to POST. |
| 429 | Rate limited | 60+ submissions to the same form within 60s, or monthly quota exhausted. | Honor Retry-After. For monthly, upgrade or wait for the 1st. |
| 500 | Internal error | Database or email-provider failure on our side. | Retry once. If persistent, email hello@splitforms.com with the timestamp. |
Rate limits
Two limits run side-by-side. A per-form burst limit of 60 submissions per rolling 60-second window protects the endpoint from runaway loops and dictionary attacks. A monthly quota gates how many submissions count against your plan. Webhook fan-out and the read API do not count against the monthly submission quota.
| Plan | Submissions / month | Per-form burst |
|---|---|---|
| Free | 1,000 | 60 / minute |
| Pro ($5/mo) | 5,000 | 60 / minute |
| 4-Year ($59 / 48mo) | 15,000 | 60 / minute |
The 429 response includes a Retry-After header set to 60seconds. After waiting that long the form's rolling window will have rotated and the next submission will be accepted.
Code examples
The same endpoint, called every common way. Replace YOUR_ACCESS_KEY with the key from your dashboard.
cURL
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 Lovelace",
"email": "ada@example.com",
"message": "Hello from cURL."
}'cURL (urlencoded — same as a browser form)
curl -X POST https://splitforms.com/api/submit \
--data-urlencode "access_key=YOUR_ACCESS_KEY" \
--data-urlencode "name=Ada Lovelace" \
--data-urlencode "email=ada@example.com" \
--data-urlencode "message=Hello from cURL."JavaScript fetch (browser or Node)
const res = await fetch("https://splitforms.com/api/submit", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({
access_key: "YOUR_ACCESS_KEY",
name: "Ada Lovelace",
email: "ada@example.com",
message: "Hello from JS.",
}),
});
const data = await res.json();
if (!data.success) throw new Error(data.message);
console.log(data); // { success: true, message: "Submission received" }Plain HTML form
<form action="https://splitforms.com/api/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<input type="hidden" name="redirect" value="https://yoursite.com/thanks" />
<input type="text" name="botcheck" style="display:none" tabindex="-1" autocomplete="off" />
<input type="text" name="name" required />
<input type="email" name="email" required />
<textarea name="message" required></textarea>
<button type="submit">Send</button>
</form>Python (requests)
import requests
res = requests.post(
"https://splitforms.com/api/submit",
json={
"access_key": "YOUR_ACCESS_KEY",
"name": "Ada Lovelace",
"email": "ada@example.com",
"message": "Hello from Python.",
},
headers={"Accept": "application/json"},
timeout=10,
)
res.raise_for_status()
data = res.json()
print(data) # {'success': True, 'message': 'Submission received'}Node.js (fetch)
const res = await fetch("https://splitforms.com/api/submit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
access_key: process.env.SPLITFORMS_KEY,
name: "Ada Lovelace",
email: "ada@example.com",
message: "Hello from Node.",
}),
});
const data = await res.json();
console.log(data); // { success: true, message: "Submission received" }Server action in Next.js (App Router)
"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 };
}Webhook delivery
When a submission is accepted, splitforms POSTs the payload to every active webhook on the account in parallel. We auto-detect Slack, Discord, and CallMeBot WhatsApp URLs and reformat for those; everything else gets the generic JSON payload below.
Headers we send
| Header | Value |
|---|---|
| Content-Type | application/json |
| X-Splitforms-Signature | sha256=<HMAC-SHA256 of the raw body using the webhook's secret> |
| X-Splitforms-Event | submission.created |
| User-Agent | splitforms.com-webhook/1.0 |
Generic payload shape
{
"event": "submission.created",
"submission": {
"id": "sub_01HZX9...",
"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"
}
}Verifying the signature
Compute HMAC-SHA256 of the rawrequest body (don't parse and re-stringify) using your webhook secret. Compare in constant time to the value after sha256=.
import crypto from "node:crypto";
function verify(rawBody, header, secret) {
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(header),
);
}Delivery semantics
- Timeout: 8 seconds per delivery attempt. Slow endpoints get marked as failed.
- Retries: none, automatically. The dashboard shows last status and last error per webhook so you can replay manually.
- Ordering: not guaranteed. Multiple webhooks fire in parallel; if you need ordering, use a queue downstream.
- Idempotency: each submission has a unique
submission.id— use it as your dedupe key in case you ever do receive a duplicate.
Read submissions API
For dashboards, exports, or syncing submissions to your own database, splitforms exposes a read endpoint. It returns paginated JSON of the submissions you would otherwise see in the dashboard, authenticated with a personal access token from Dashboard → API.
curl "https://splitforms.com/api/submissions?form=contact&page=1&per_page=50" \
-H "Authorization: Bearer YOUR_PERSONAL_ACCESS_TOKEN"Example response:
{
"page": 1,
"per_page": 50,
"total": 2,
"submissions": [
{
"id": "sub_01HZX9...",
"form": "contact",
"created_at": "2026-05-02T14:31:04Z",
"fields": {
"name": "Ada Lovelace",
"email": "ada@example.com",
"message": "Hello."
}
},
{
"id": "sub_01HZX8...",
"form": "contact",
"created_at": "2026-05-02T13:02:11Z",
"fields": {
"name": "Grace Hopper",
"email": "grace@example.com",
"message": "Bug found."
}
}
]
}SDKs and libraries
There is intentionally no first-party SDK. The endpoint is one HTTP POST with a flat body, so the platform-native HTTP client in every language already does the job — adding an SDK would just be extra dependencies to update.
- Browsers:
fetchor a plain<form action>. - Node.js: built-in
fetch(Node 18+) orundici. - Python:
requestsorhttpx. - Go:
net/http. - Ruby:
Net::HTTPorfaraday. - PHP:
cURLorGuzzle. - AI agents: the splitforms MCP server exposes form/submission tools to Claude Code, Cursor, Windsurf.
How to test
Two recommended paths for confirming an integration works without polluting your real submissions table.
1. The hosted live test form
Visit splitforms.com/test-form for a working /api/submit form wired up to a sandbox key. Use it to confirm the endpoint is up, see exactly what a successful response looks like, and spot-check email delivery.
2. cURL against your real key
Fastest way to confirm yourkey works. The submission shows up in your dashboard immediately — delete it after if you don't want it cluttering your table.
curl -X POST https://splitforms.com/api/submit \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"access_key": "YOUR_ACCESS_KEY",
"name": "Test from cURL",
"email": "you@example.com",
"message": "Confirming integration works."
}'Expected response:
{ "success": true, "message": "Submission received" }Testing webhooks
In Dashboard → Webhooks, every webhook has a Test button that fires a sample submission.created payload signed with your real secret. Use it to verify your signature verification logic before going live.
Common errors and one-line fixes
For a deeper troubleshooting guide (webhook timeouts, deliverability, CORS, honeypot misfires) see the docs troubleshooting section.
HTTP 400 — "Missing access_key"
You posted a form without the access_key field, or with an empty value. Add <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" /> inside your form, or include access_key in your JSON body.
HTTP 403 — "Origin not allowed"
Your form has an allowed-domain list set, and the request came from an Origin that is not on it. Edit the form in the dashboard and add the missing domain (with and without the www. prefix to be safe). Local development from localhost is allowed by default.
HTTP 404 — "Invalid access_key"
The supplied key doesn't match any form on any account. Most often: trailing whitespace pasted with the key, or a stale value served from a CDN cache after a key rotation. Re-copy from the dashboard.
HTTP 429 — "Too many submissions"
You hit the per-form burst limit of 60/minute. The response includes a Retry-After: 60header. If you see this from real users, it's almost always a script retrying on every keystroke instead of on submit.
API FAQ
Is the splitforms API really free?
Yes. The free tier gives you 1,000 submissions per month, unlimited forms, and full access to the same /api/submit endpoint that paid users hit. There is no credit card required, no trial expiry, and no per-request charge. The Pro and 4-Year plans only raise quota and add convenience features like per-form CC/BCC and allowed-domain locking — the API surface is identical.
What's the rate limit for the form API?
Roughly 60 submissions per minute per form, applied as a sliding 60-second window. If you go over you get HTTP 429 with a Retry-After header set to 60 seconds. Monthly quota is enforced separately: 1,000/month on Free, 5,000/month on Pro, 15,000/month on the 4-Year plan. Bursts above the per-form limit are usually bots — real users almost never hit it.
Do I need an SDK or client library to use the API?
No. There is intentionally no SDK. The API is one HTTP endpoint that accepts standard form encodings, so the platform-native HTTP client in any language works — fetch in browsers and Node, requests in Python, http.Client in Go, URLSession in Swift, or just an HTML form with action="https://splitforms.com/api/submit". An SDK would be net-negative weight for a one-endpoint API.
How do I authenticate the read submissions API?
Generate a personal access token in Dashboard → API, then send it as Authorization: Bearer YOUR_TOKEN on requests to GET /api/submissions. Tokens are scoped to your account and can be rotated or revoked individually. The submit endpoint itself uses the public access_key field instead of a Bearer token, because access_key is meant to live in client-side HTML.
Can I send JSON instead of form-encoded data?
Yes. The /api/submit endpoint accepts application/json, application/x-www-form-urlencoded, and multipart/form-data interchangeably. Use whichever is easiest from your stack. JSON is the most natural fit for fetch() in React, server actions in Next.js, and serverless function gateways. Set Content-Type: application/json and POST a flat object with access_key plus your fields.
How do I redirect after a successful submit?
Include a redirect field in the submitted payload — for example <input type="hidden" name="redirect" value="https://yoursite.com/thanks" /> in an HTML form. After a successful submission the API issues a 302 redirect to that URL. If the request asks for JSON (Accept: application/json), the API returns { success: true, message: "Submission received" } instead.
Is there a webhook signature for verifying outbound webhooks?
Yes — outbound webhooks include an X-Splitforms-Signature header carrying an HMAC-SHA256 of the raw body in the format sha256=<hex>, signed with the webhook secret you set in your dashboard. Verify by recomputing HMAC-SHA256(secret, rawBody) and constant-time comparing to the header. We also send X-Splitforms-Event: submission.created so you can route by event type.
Can I lock my access key to specific domains?
Yes — and you should. In your dashboard, set an allowed-domain list per form (e.g. yoursite.com, www.yoursite.com). Submissions from any other Origin or Referer will be rejected with HTTP 403 and message "Origin not allowed". This stops scrapers from copying your key and using it from their own sites. Localhost is exempt by default so local dev keeps working.