splitforms.com
API REFERENCE · V1 · STABLE

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 · 500/mo

1
endpoint
3
content types
14ms
median p50, edge
60
submissions / minute
Base endpoint
POST https://splitforms.com/api/submit

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.

application/jsonapplication/x-www-form-urlencodedmultipart/form-data

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

HeaderRequiredDescription
Content-TypeRecommendedapplication/json, application/x-www-form-urlencoded, or multipart/form-data. If omitted, body is parsed as urlencoded.
AcceptOptionalSet to application/json to force a JSON response (otherwise we may render a redirect or success HTML).
Origin / RefererOptionalUsed for allowed-domain checking when the form has an allowlist set. CORS is open: any origin may POST.

Required body field

FieldTypeDescription
access_keystringYour 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.

FieldTypeDescription
botcheckstringHoneypot. If non-empty the request is silently accepted (200) but the row is flagged is_spam=true and no email/webhook fires.
redirectURLAbsolute URL to redirect to on success (HTTP 302). If omitted and the request asks for JSON, the API returns JSON instead.
subjectstringOverride the email subject line for this submission. Falls back to the form's subject_template, then to "New form submission".
from_namestringOverride the From display name on the notification email.
replytoemailSet the Reply-To header so hitting Reply replies to the visitor. If omitted, falls back to the email field if one exists.
g-recaptcha-responsestringStandard reCAPTCHA token field name. Stripped from saved data; render a reCAPTCHA widget client-side if you want one.
cf-turnstile-responsestringStandard 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

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/thanks

Successful 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"
}

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.

StatusMeaningWhen it firesHow to fix
400Bad requestBody 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.
403ForbiddenRequest 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.
404Invalid access_keyNo form on any account matches the supplied access_key.Re-copy the key from the dashboard. Watch for trailing whitespace.
405Method not allowedYou sent GET, PUT, DELETE, etc. The endpoint accepts only POST and OPTIONS.Switch to POST.
429Rate limited60+ submissions to the same form within 60s, or monthly quota exhausted.Honor Retry-After. For monthly, upgrade or wait for the 1st.
500Internal errorDatabase 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.

PlanSubmissions / monthPer-form burst
Free50060 / minute
Pro ($5/mo)5,00060 / minute
3-Year ($59 / 36mo)15,00060 / 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 };
}

PHP (cURL)

<?php
$payload = json_encode([
    "access_key" => "YOUR_ACCESS_KEY",
    "name"       => "Ada Lovelace",
    "email"      => "ada@example.com",
    "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:      "ada@example.com",
  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:     "ada@example.com",
		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":      "ada@example.com",
            "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 gated behind the Storage integration — connect it once in Dashboard → Integrations → Storage and every form on your account starts accepting attachments.

Limits

LimitValueBehavior on exceed
Files per submission5Extra files dropped, text fields still saved.
Max size per file10 MBOversize file rejected, others still uploaded.
Max field count (text + file)100 (MAX_FIELDS)Extra fields dropped silently.
Max bytes per text field10,000 (MAX_FIELD_BYTES)Truncated, not rejected.

Storage integration not connected

If a submission arrives with files but Storage isn't 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. Starter or above is required for retained file uploads when Storage is connected.

{
  "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=ada@example.com" \
  -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 row is flagged is_spam = true and the email + webhook fan-out is skipped. 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 valueSaved?Email sent?Webhook fired?Status
empty / missingYes (clean)YesYes200
non-empty (bot)Yes (flagged is_spam)NoNo200

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

The submit endpoint is fully cross-origin. Any browser, any origin, any framework — POST works without a preflight gymnastics. The response always includes:

Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Accept, Authorization
Access-Control-Max-Age: 86400

Browsers 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_keylives in the client-side payload, open CORS is the right default. The key is protected from abuse by the spam classifier, rate limiter, and your plan's monthly cap — not by origin headers, which CORS makes unreliable as an authentication signal anyway.

API versioning policy

The submit endpoint is currently unversioned — the URL is just POST /api/submit. Treat this as v1, stable, additive-only:

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

HeaderValue
Content-Typeapplication/json
X-Splitforms-Signaturesha256=<HMAC-SHA256 of the raw body using the webhook's secret>
X-Splitforms-Eventsubmission.created
User-Agentsplitforms.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

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.

GET https://splitforms.com/api/submissions
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

Official JavaScript SDK — @splitforms/js

For TypeScript and JavaScript projects we publish a tiny, zero-dep wrapper around POST /api/submit. It's the official splitforms SDK and is mostly a typed convenience over fetch — useful if you want autocomplete, a typed response, and a single import.

npm install @splitforms/js
import { submit } from "@splitforms/js";

const result = await submit({
  accessKey: process.env.SPLITFORMS_KEY!,
  fields: {
    name: "Ada Lovelace",
    email: "ada@example.com",
    message: "Hello from the SDK.",
  },
});

if (!result.success) throw new Error(result.message);
// result.success === true, result.message === "Submission received"

Package: npmjs.com/package/@splitforms/js. The SDK is optional — you can hit the API directly from any language, and many of the examples on this page do exactly that.

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.

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 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 500 submissions per month, 2 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 3-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: 500/month on Free, 5,000/month on Pro, 15,000/month on the 3-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.

How is my access key protected from abuse if someone copies it?

Every submission runs through the spam classifier (honeypot + AI scoring), per-key rate limits, and your plan's monthly cap. If a scraper copies your key and starts submitting from another site, those layers catch and throttle them, and the submissions count against the scraper's IP rate limit, not just your plan. 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.

✻ ✻ ✻

One endpoint. The whole integration.

Get your free access key, drop it into your form, and ship. 500 submissions/month forever — no credit card.

Get free access key →Read the docs