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
6/min
per-IP burst limit
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), 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

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.
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 is configured in the form's dashboard settings and the request did not ask for JSON. A submitted redirect field is ignored.

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

{
  "success": false,
  "message": "Too many submissions for this form right now. Wait a minute and try again.",
  "code": "rate_limited"
}

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 limitedMore than 6 submissions from one IP to the same form within 60s (or 20 within 15 minutes), or monthly quota exhausted.Wait 60 seconds and retry. 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

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. 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 / monthBurst (per IP per form)Sustained (per IP per form)
Free5006 / minute20 / 15 minutes
Pro ($5/mo)5,0006 / minute20 / 15 minutes
3-Year ($59 / 36mo)15,0006 / minute20 / 15 minutes

The 429 response does not currently include a Retry-After header. Wait 60seconds 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": "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="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 file5 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": "3f9a2b7e-4c1d-4e9a-9b2f-7a6c1d3e4f5a",
    "form_name": "Contact",
    "data": {
      "name": "Ada Lovelace",
      "email": "ada@example.com",
      "message": "Hello."
    },
    "ip_address": "203.0.113.7",
    "referer": "https://yoursite.com/contact",
    "created_at": "2026-05-04T14:31:04.123Z"
  }
}

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

GET https://splitforms.com/api/submissions

Query parameters

ParamTypeDescription
formstringOptional. Scope to one form — accepts either the form's id or its access_key. Omit to return submissions across every form on the account.
limitintegerOptional. Max rows to return. Default 25, maximum 100. Must be a positive integer or the request is rejected with 400.
beforeISO-8601 timestampOptional. Return only submissions created strictly before this timestamp. Pass the created_at of the oldest row you've seen to page backward through history.
sinceISO-8601 timestampOptional. 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 areincluded: 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": "ada@example.com",
        "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": "grace@example.com",
        "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

StatusWhen it fires
401Authorization header is missing, isn't a Bearer token, or the token doesn't match any account.
400form doesn't match a form on this account, or limit/since/before is present but not validly formatted.
429More than 60 requests in a rolling 60-second window for this token. Response includes Retry-After: 60.
500Database 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

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-IP burst limit of 6 submissions per minute for this form, or the sustained limit of 20 per 15 minutes. No Retry-Afterheader 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.

API FAQ

Is the splitforms API really free?

Yes. The free tier gives you 500 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 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?

6 submissions per minute per IP address per form, applied as a sliding 60-second window, plus a sustained limit of 20 submissions per 15 minutes from the same IP. 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. 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-IP limit are usually bots — real users almost never hit it.

Do I need an SDK or client library to use the API?

No. The official @splitforms/js package is an optional typed wrapper, not a requirement. 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.

How do I redirect after a successful submit?

Set the redirect URL for the form in Dashboard → Form settings → Redirect. After a successful native HTML submission, the API issues a 302 redirect to that configured URL. A redirect field submitted by a visitor is intentionally ignored to prevent open redirects. 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