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 · 1,000/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"
}

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.

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
Free1,00060 / minute
Pro ($5/mo)5,00060 / minute
4-Year ($59 / 48mo)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 };
}

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

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.

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.

✻ ✻ ✻

One endpoint. The whole integration.

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

Get free access key →Read the docs