splitforms API reference
Updated · plan facts checked against /pricing
To submit a form to splitforms, POST your access_key and fields to https://splitforms.com/api/submit as JSON, urlencoded or multipart data. Success returns 200 with { success: true, message }; errors return a 4xx or 5xx with success: false, a message and usually a code. More than 6 requests a minute, or 20 per 15 minutes, per IP, per form returns 429.
This is the endpoint reference for the splitforms contact form API. Machine-readable version: OpenAPI 3.1 spec (openapi.json).
Free · No credit card · 500 submissions total
Endpoint
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, add your domains under Allowed Domains and turn on strict domain protection in the form's settings (available on every plan, including Free).
<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), use the API token from Dashboard → MCP. It's the same bearer token that authenticates the MCP server — one token powers both. It scopes to your account, never appears in client-side code, and can be regenerated or revoked from the dashboard at any time.
curl https://splitforms.com/api/submissions \
-H "Authorization: Bearer YOUR_API_TOKEN"Request headers and fields
Request headers
| Header | Required | Description |
|---|---|---|
| Content-Type | Required | application/json, application/x-www-form-urlencoded, or multipart/form-data. Any other value, or no Content-Type at all, returns 415. Browsers, curl -d/-F and fetch with a FormData body set it for you. |
| Accept | Optional | Set to application/json to force a JSON response (otherwise we may render a redirect or success HTML). |
| Origin / Referer | Optional | Checked against the form's Allowed Domains list. A mismatch is only rejected (403) when strict domain protection is on. 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 dropped: it returns 200 { success: true }, but nothing is saved and no email or webhook fires. |
| redirect | URL or path | Where to send the visitor after a successful submit. Native form posts get a 303 to it; JSON callers get it back as a redirect field. Must be a relative path, the submitting site, a domain on Allowed Domains, or splitforms.com — anything else is ignored. Works on every plan. |
| 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 (
MAX_FIELDS). Extra fields are dropped silently. - Maximum value length per field: 10,000 bytes (
MAX_FIELD_BYTES). Longer values are truncated, not rejected. - File uploads via
multipart/form-dataare accepted on paid plans when the Storage integration is connected. Maximum 5 files / submission at 5 MB each. A request body larger than that plus about 2 MB of fields returns 413. See the File uploads section below. - CORS is open: any
Originmay POST. Domain restriction is opt-in per form: add your domains under Allowed Domains and turn on strict domain protection.
Responses
What you get back depends on the request shape. JSON requests get JSON. Native HTML form posts that carry a hidden redirect field get a 303; posts to a form with a redirect URL set in its dashboard settings (paid plans) get a 302. Native posts with neither get a built-in success page.
Successful submission (JSON)
Returned when Content-Type or Accept includes application/json, or when a script calls the endpoint without asking for HTML (fetch, curl, Python requests).
HTTP/1.1 200 OK
Content-Type: application/json
{
"success": true,
"message": "Submission received"
}Optional fields in the JSON response
These keys appear only when they apply, so treat unknown keys as forward-compatible.
| Field | When it appears |
|---|---|
| redirect | The request carried a valid hidden redirect field. Navigate there yourself, e.g. window.location.href = data.redirect. |
| note | Uploaded files were dropped (Free plan, Storage not connected, or storage full). Text fields were still saved. |
| files | The request carried file parts, or a field that looks like a filename sent as text: { received, stored, hint? }. |
| payment_url | The form takes payments. Send the visitor here to finish checkout. |
Successful submission (redirect)
A native HTML form post with a hidden redirect field gets a 303, so the browser follows with a GET and a refresh never resubmits. The URL must be a relative path, the site the form was submitted from, a domain on the form's Allowed Domains list, or splitforms.com; anything else is ignored and the visitor sees the default success page. This works on every plan.
HTTP/1.1 303 See Other
Location: https://yoursite.com/thanksWithout the hidden field, a redirect URL set in the form's dashboard settings (paid plans) is sent as a 302.
HTTP/1.1 302 Found
Location: https://yoursite.com/thanksSuccessful submission (default HTML)
Returned for native HTML form submits with no redirect — 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. Copy the form access key from your splitforms dashboard and include it as a hidden input named access_key.",
"code": "missing_access_key"
}Invalid access key
HTTP/1.1 404 Not Found
Content-Type: application/json
{
"success": false,
"message": "Invalid access_key. Copy the form access key from your splitforms dashboard, not your MCP token or account API token.",
"code": "invalid_access_key"
}Rate limited
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
{
"success": false,
"message": "Too many submissions for this form right now. Wait a minute and try again.",
"code": "rate_limited"
}Free allowance used up
The Free plan's 500 submissions are a one-time total, not a monthly allowance.
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
{
"success": false,
"message": "Free plan limit reached (500 total submissions). The free allowance is one-time, not monthly. Upgrade at https://splitforms.com/#pricing to keep receiving leads.",
"code": "quota_reached",
"quota": { "limit": 500, "used": 500, "plan": "free" }
}Monthly quota exhausted (paid plans)
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
{
"success": false,
"message": "Monthly submission limit reached (5000 on the Pro plan). Resets Nov 1. Upgrade at https://splitforms.com/#pricing",
"code": "quota_reached",
"quota": { "limit": 5000, "used": 5000, "plan": "pro" }
}Error codes
For JSON callers, every error response is JSON with { success: false, message: "…" }, and most also carry a machine-readable code such as rate_limited or invalid_access_key. Native HTML form posts get a short error page with the same status. 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 | Strict domain protection is on and the request's Origin/Referer isn't on the form's Allowed Domains list, or the form is turned off. | Add the domain in the dashboard (or turn strict protection off for server clients), or turn the form back on. |
| 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. |
| 413 | Payload too large | The request body is bigger than 5 files of 5 MB each plus about 2 MB of form fields. | Send fewer or smaller files. |
| 415 | Unsupported content type | Content-Type is missing or isn't JSON, urlencoded or multipart (fetch sends a plain string body as text/plain). | Set Content-Type: application/json for JSON, or send FormData / URLSearchParams. |
| 429 | Rate or quota limit | More than 6 submissions from one IP to the same form within 60s (or 20 within 15 minutes), or the plan allowance is used up (code: quota_reached). | Wait 60 seconds and retry. For quota, upgrade, or on a paid plan wait for the monthly reset. |
| 500 | Internal error | Database or email-provider failure on our side. | Retry once. If persistent, email [email protected] with the timestamp. |
Rate limits
Three limits run side-by-side. A burst limit of 6 submissions per rolling 60-second window and a sustained limit of 20 submissions per 15 minutes protect the endpoint from runaway loops and dictionary attacks — both are keyed per IP address, per form, so one throttled visitor never blocks the rest of your traffic. Your plan allowance gates how many submissions count against your plan: 500 in total (one-time) on Free, and a monthly allowance on paid plans. Webhook fan-out and the read API do not count against the submission allowance.
| Plan | Submission allowance | Burst (per IP, per form) | Sustained (per IP, per form) |
|---|---|---|---|
| Free | 500 total (one-time) | 6 / minute | 20 / 15 minutes |
| Pro ($5/mo) | 5,000 / month | 6 / minute | 20 / 15 minutes |
| Annual ($49/yr) | 15,000 / month | 6 / minute | 20 / 15 minutes |
The 429 response does not currently include a Retry-After header. Wait 60 seconds before retrying — by then the rolling burst window will have rotated. If you're tripping the sustained limit, back off for 15 minutes.
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": "[email protected]",
"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 protected]" \
--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: "[email protected]",
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="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": "[email protected]",
"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: "[email protected]",
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 };
}PHP (cURL)
<?php
$payload = json_encode([
"access_key" => "YOUR_ACCESS_KEY",
"name" => "Ada Lovelace",
"email" => "[email protected]",
"message" => "Hello from PHP.",
]);
$ch = curl_init("https://splitforms.com/api/submit");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Content-Type: application/json",
"Accept: application/json",
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
throw new Exception("submit failed: $status $response");
}
$data = json_decode($response, true);
// $data === ['success' => true, 'message' => 'Submission received']Ruby (Net::HTTP)
require "net/http"
require "uri"
require "json"
uri = URI("https://splitforms.com/api/submit")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
req = Net::HTTP::Post.new(uri.path, {
"Content-Type" => "application/json",
"Accept" => "application/json",
})
req.body = {
access_key: "YOUR_ACCESS_KEY",
name: "Ada Lovelace",
email: "[email protected]",
message: "Hello from Ruby.",
}.to_json
res = http.request(req)
raise "submit failed: #{res.code} #{res.body}" unless res.code == "200"
data = JSON.parse(res.body)
puts data # => {"success"=>true, "message"=>"Submission received"}Go (net/http)
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
type Submission struct {
AccessKey string `json:"access_key"`
Name string `json:"name"`
Email string `json:"email"`
Message string `json:"message"`
}
func main() {
body, _ := json.Marshal(Submission{
AccessKey: "YOUR_ACCESS_KEY",
Name: "Ada Lovelace",
Email: "[email protected]",
Message: "Hello from Go.",
})
req, _ := http.NewRequest("POST",
"https://splitforms.com/api/submit",
bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
panic(fmt.Errorf("submit failed: %d %s", resp.StatusCode, out))
}
fmt.Println(string(out)) // {"success":true,"message":"Submission received"}
}Rust (reqwest)
use reqwest::Client;
use serde_json::json;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::new();
let res = client
.post("https://splitforms.com/api/submit")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.json(&json!({
"access_key": "YOUR_ACCESS_KEY",
"name": "Ada Lovelace",
"email": "[email protected]",
"message": "Hello from Rust.",
}))
.send()
.await?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
return Err(format!("submit failed: {status} {body}").into());
}
let data: serde_json::Value = res.json().await?;
println!("{data}"); // {"success": true, "message": "Submission received"}
Ok(())
}File uploads
splitforms accepts file attachments via multipart/form-data. Files are stored alongside the submission and linked from the dashboard so recipients can download them. Uploads are a paid-plan feature (Pro and Annual) and are gated behind the Storage integration — connect it once in Dashboard → Integrations → Storage and every form on your account starts accepting attachments.
Limits
| Limit | Value | Behavior on exceed |
|---|---|---|
| Files per submission | 5 | Extra files dropped, text fields still saved. |
| Max size per file | 5 MB | Oversize file rejected, others still uploaded. |
| Max field count (text + file) | 100 (MAX_FIELDS) | Extra fields dropped silently. |
| Max bytes per text field | 10,000 (MAX_FIELD_BYTES) | Truncated, not rejected. |
Free plan or Storage not connected
If a submission arrives with files on the Free plan, or on a paid plan without Storage connected, the API still saves the text fields. Files are dropped and a note is included in the JSON success response so the caller can surface a warning. If the account's storage is full, the note says so instead.
{
"success": true,
"message": "Submission received",
"note": "Files were dropped — connect Storage in your dashboard to receive uploads."
}Multipart example (cURL)
curl -X POST https://splitforms.com/api/submit \
-F "access_key=YOUR_ACCESS_KEY" \
-F "name=Ada Lovelace" \
-F "[email protected]" \
-F "message=Resume attached." \
-F "resume=@/path/to/resume.pdf"HTML form with file input
<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="text" name="name" required />
<input type="email" name="email" required />
<input type="file" name="resume" accept=".pdf,.doc,.docx" />
<button type="submit">Send</button>
</form>Honeypot (anti-spam)
splitforms ships a built-in honeypot — a hidden form field named botcheck that real users never fill in. When the field comes back non-empty, the API still returns HTTP 200 so the bot thinks it succeeded, but the submission is dropped: nothing is saved, and no email or webhook fires. The bot wastes its budget; you never see the noise.
How to add the honeypot
<input
type="text"
name="botcheck"
tabindex="-1"
autocomplete="off"
style="position:absolute;left:-9999px;opacity:0"
/>Behavior matrix
| Honeypot value | Saved? | Email sent? | Webhook fired? | Status |
|---|---|---|---|---|
| empty / missing | Yes (clean) | Yes | Yes | 200 |
| non-empty (bot) | No (dropped) | No | No | 200 |
You can disable the honeypot per form by setting honeypot_mode to none in the dashboard. We recommend leaving it on — false positives are vanishingly rare.
CORS
You can call the submit endpoint with fetch from any site. It is fully cross-origin: any browser, any origin, any framework, no proxy needed. The response always includes:
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Accept
Access-Control-Max-Age: 86400Browsers send an OPTIONS preflight for non-simple requests (e.g. Content-Type: application/json). splitforms responds 204 No Content with the same CORS headers, so the actual POST goes through without a hitch.
Because access_key lives in the client-side payload, open CORS is the right default. The key is protected from abuse by spam filtering, the per-IP rate limits and your plan's submission allowance. To restrict which sites can post from a browser, add your domains under Allowed Domains and turn on strict domain protection in the form's settings. Leave it off for server, mobile or curl clients, which don't send a browser Origin header.
API versioning policy
The submit endpoint is currently unversioned — the URL is just POST /api/submit. Treat this as v1, stable, additive-only:
- No breaking changes will ship at this URL. If we ever need to break the request or response shape, the new behavior will live at
/api/v2/submitand the v1 URL will continue to work. - New optional fields may be added. Treat unknown keys in responses as forward-compatible — don't parse with a strict schema that throws on unrecognized properties.
- The URL is a permanent part of your integration. You can hardcode it in HTML forms, server actions, mobile apps, and one-off scripts without worrying about a migration deadline.
- Status codes are stable. A 200 today is a 200 tomorrow. Branch on HTTP status, not on the human-readable message string.
Webhook delivery
Signed with HMAC-SHA256 (sha256= signature header); 8-second timeout; one automatic retry after about 2 seconds on network errors, timeouts and 5xx responses (4xx is not retried); delivery log in the dashboard. Paid plans (Pro and Annual).
When a submission is accepted, splitforms POSTs the payload to every active webhook for that form, plus any account-wide webhooks, in parallel. We auto-detect Slack, Discord, Google Chat, Microsoft Teams, Telegram 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": "3f9a2b7e-4c1d-4e9a-9b2f-7a6c1d3e4f5a",
"form_name": "Contact",
"data": {
"name": "Ada Lovelace",
"email": "[email protected]",
"message": "Hello."
},
"ip_address": "203.0.113.7",
"referer": "https://yoursite.com/contact",
"created_at": "2026-05-04T14:31:04.123Z",
"files": [
{
"field": "resume",
"name": "ada-lovelace-resume.pdf",
"size_bytes": 184320,
"content_type": "application/pdf",
"download_url": "https://…signed URL, valid ~7 days…"
}
]
}
}files is present only when the submission carried stored uploads. Each download_url is a pre-signed direct link (no login required, expires after ~7 days) — fetch it from your endpoint or automation (Make, Zapier, n8n) to copy the file into your own storage, e.g. Google Drive or S3, before it expires.
Verifying the signature
Compute HMAC-SHA256 of the raw request 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");
if (!header || header.length !== expected.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(header),
);
}Delivery semantics
- Timeout: 8 seconds per delivery attempt.
- Retries: one automatic retry after about 2 seconds when the attempt times out, can't connect or gets a 5xx. A 4xx response is not retried. If the retry also fails, the delivery is marked failed; the dashboard shows the last status and last error per webhook. After fixing an endpoint, send a new test submission to verify delivery.
- 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. A retry after a timeout can deliver a submission your endpoint already processed.
Read submissions API
For dashboards, exports, or syncing leads into your own database, splitforms exposes a read-only endpoint over the submissions you already see in your inbox. It's authenticated with the same API token as the MCP server — generate it once at Dashboard → MCP.
Query parameters
| Param | Type | Description |
|---|---|---|
| form | string | Optional. Scope to one form — accepts either the form's id or its access_key. Omit to return submissions across every form on the account. |
| limit | integer | Optional. Max rows to return. Default 25, maximum 100. Must be a positive integer or the request is rejected with 400. |
| before | ISO-8601 timestamp | Optional. Return only submissions created strictly before this timestamp. Pass the created_at of the oldest row you've seen to page backward through history. |
| since | ISO-8601 timestamp | Optional. Return only submissions created at or after this timestamp — for incremental sync ("everything new since I last checked"). |
Rows are always ordered newest first (created_at descending). The endpoint excludes recycle-bin (deleted) and spam-flagged submissions — the same baseline your dashboard inbox shows. Archived submissions are included: archiving hides a row from the inbox view, it doesn't delete it.
cURL
curl "https://splitforms.com/api/submissions?limit=50" \
-H "Authorization: Bearer YOUR_API_TOKEN"Example response:
{
"submissions": [
{
"id": "b6d1f9de-8a3f-4b7e-9c2a-1f4e6d8b2c31",
"form_id": "a1b2c3d4e5f647a89b0c1d2e3f4a5b6c",
"created_at": "2026-07-08T14:31:04.123Z",
"data": {
"name": "Ada Lovelace",
"email": "[email protected]",
"message": "Hello."
},
"is_spam": false
},
{
"id": "7c2e4a1b-9f3d-4c8a-b1e6-2d5f8a9c3b47",
"form_id": "a1b2c3d4e5f647a89b0c1d2e3f4a5b6c",
"created_at": "2026-07-08T13:02:11.045Z",
"data": {
"name": "Grace Hopper",
"email": "[email protected]",
"message": "Bug found."
},
"is_spam": false
}
],
"has_more": true
}Paging through results
There's no page-number or offset counting — pass the created_at of the oldest row you received as before to fetch the next batch, and stop once has_more comes back false.
curl "https://splitforms.com/api/submissions?limit=50&before=2026-07-08T13:02:11.045Z" \
-H "Authorization: Bearer YOUR_API_TOKEN"Filter to one form
curl "https://splitforms.com/api/submissions?form=a1b2c3d4e5f647a89b0c1d2e3f4a5b6c" \
-H "Authorization: Bearer YOUR_API_TOKEN"form also accepts the form's access_key (the same value used with POST /api/submit), so you don't need to look up the internal form id separately.
Errors
| Status | When it fires |
|---|---|
| 401 | Authorization header is missing, isn't a Bearer token, or the token doesn't match any account. |
| 400 | form doesn't match a form on this account, or limit/since/before is present but not validly formatted. |
| 429 | More than 60 requests in a rolling 60-second window for this token. Response includes Retry-After: 60. |
| 500 | Database error on our side. Safe to retry once. |
Every error response is JSON with { "error": "…" } — branch on the HTTP status, not the message text.
Rate limit
60 requests per minute per API token — independent of the POST /api/submit limits above. Responses always send Cache-Control: no-store, so cache the result on your end if you're polling on an interval.
SDKs and libraries
TypeScript and JavaScript — use fetch
There is no SDK to install. The endpoint takes standard FormData or JSON, so fetch is the whole integration — this snippet is complete and typed, with no dependency to add or keep up to date.
type SubmitResult =
| {
success: true;
message: string;
redirect?: string; // hidden redirect field, validated
note?: string; // files were dropped
files?: { received: number; stored: number; hint?: string };
}
| { success: false; message: string; code?: string };
export async function submit(fields: Record<string, string>) {
const res = await fetch("https://splitforms.com/api/submit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
access_key: process.env.NEXT_PUBLIC_SPLITFORMS_KEY,
...fields,
}),
});
const result = (await res.json()) as SubmitResult;
if (!result.success) throw new Error(result.message);
return result;
}Sending a real <form>? Pass new FormData(form) as the body and drop the Content-Type header — the browser sets the multipart boundary for you, and file inputs are carried automatically.
Other languages — use the platform HTTP client
We don't publish per-language SDKs for a one-endpoint API. Every modern language ships a perfectly capable HTTP client; using it is one fewer dependency to keep current.
- Browsers:
fetchor a plain<form action>. - Node.js: built-in
fetch(Node 18+) orundici. - Python:
requestsorhttpx. - Go:
net/http. - Ruby:
Net::HTTPorfaraday. - PHP:
cURLorGuzzle. - Rust:
reqwestwithtokio. - 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 your key 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": "[email protected]",
"message": "Confirming integration works."
}'Expected response:
{ "success": true, "message": "Submission received" }Testing webhooks
In Dashboard → Integrations → 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 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-IP burst limit of 6 submissions per minute for this form, or the sustained limit of 20 per 15 minutes. No Retry-After header is sent — wait 60 seconds and retry. If you see this from real users, it's almost always a script retrying on every keystroke instead of on submit.
HTTP 415 — "Unsupported content type"
The request had no Content-Type, or one the endpoint doesn't accept. The usual cause is fetch with a JSON.stringify() body and no headers, which browsers send as text/plain. Add Content-Type: application/json, or post a FormData object instead.
API FAQ
Is the splitforms API really free?
Yes. The submit endpoint works on every plan, including Free (500 submissions total, one-time), with no credit card. Paid plans (Pro at $5/month and Annual at $49/year) add webhooks, integrations, exports, file uploads, CC/BCC recipients and the auto-responder. Annual adds volume only: 15,000 submissions a month and 5 GB of storage, against Pro's 5,000 and 500 MB. Allowed-domain locking is on every plan, including Free.
What error codes can /api/submit return?
400 (the body couldn't be parsed or access_key is missing), 403 (the form is turned off, or strict domain protection blocked the request), 404 (invalid access_key), 405 (a method other than POST), 413 (request body too large), 415 (unsupported or missing Content-Type), 429 (a rate or quota limit) and 500 (a server error). JSON callers get success: false and a message on every error, plus a machine-readable code on most. For 429, wait about a minute before retrying.
What's the rate limit for the form API?
6 submissions per minute and 20 per 15 minutes per IP, per form. If you go over you get HTTP 429 — wait 60 seconds and retry (the response does not currently include a Retry-After header). Because both limits are keyed per visitor IP, one throttled visitor never blocks the rest of your traffic. Your plan allowance is enforced separately: 500 total (one-time) on Free, 5,000 a month on Pro and 15,000 a month on Annual. Bursts above the per-IP limit are usually bots — real users almost never hit it.
Does the API support CORS for browser fetch?
Yes. The submit endpoint accepts cross-origin requests and answers the OPTIONS preflight, so you can call it with fetch from any site. To restrict which sites can post, add your domains under Allowed Domains and turn on strict domain protection in the form's settings. Leave strict protection off for server, mobile or curl clients, which don't send a browser Origin header.
Do I need an SDK or client library to use the API?
No — there is no SDK at all, and none is needed. 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 a plain HTML form action.
How do I authenticate the read submissions API?
Use the API token from Dashboard → MCP (splitforms.com/dashboard/mcp) — it's the same bearer token that authenticates the MCP server, so one token powers both. Send it as Authorization: Bearer YOUR_API_TOKEN on requests to GET /api/submissions. It's a single token per account: regenerating it invalidates the old value for both the REST API and MCP at once, and revoking it disables both. 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. JSON can't carry files; use multipart/form-data for uploads.
Can I upload files through the API?
Yes, with multipart/form-data: up to 5 files of 5 MB each per submission on paid plans with Storage connected. On the Free plan, files are dropped, text fields are saved, and the JSON response includes a note.
How do I redirect after a successful submit?
Add a hidden field named redirect, for example <input type="hidden" name="redirect" value="https://yoursite.com/thanks">. Native HTML form posts get a 303 to that URL; fetch and JSON callers get it back as a redirect field in the response and navigate themselves. The URL must be a relative path, the site the form was submitted from, a domain on the form's Allowed Domains list, or splitforms.com — anything else is ignored, so the endpoint can't be used as an open redirect. The hidden field works on every plan. Paid plans can also set a default redirect URL in the form's settings.
Do webhooks retry if my endpoint fails?
Yes, once. If your endpoint times out after 8 seconds, returns a 5xx or can't be reached, splitforms retries after about 2 seconds; 4xx responses aren't retried. Deliveries are signed with HMAC-SHA256 and logged in the dashboard. Webhooks are on paid plans.
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.
How is my access key protected from abuse if someone copies it?
Every submission runs through smart spam filtering (a honeypot field and automatic, rule-based spam scoring of the content), per-IP rate limits for each form, and your plan's submission allowance. If a scraper copies your key and starts submitting from another site, those layers catch and throttle it. Origin-based enforcement is optional: turn on strict domain protection only for browser forms where you control the allowed domains. Leave it off for direct API, server, mobile app, curl, or older HTML integrations because Origin and Referer headers are not reliable in those environments.
Is there an OpenAPI spec or Postman collection?
Yes, an OpenAPI 3.1 spec for POST /api/submit is published at https://splitforms.com/openapi.json, and any tool that reads OpenAPI 3.1 can import it. It lists every request field, response shape and error code on this page. There is no separate Postman collection; use the code examples here for cURL, fetch, Python, PHP and Go, and the MCP server if you're connecting an AI agent.