Integration reference
Quickstart, authentication, framework recipes, webhooks, email deliverability, spam protection, and a complete troubleshooting playbook — everything you need to ship splitforms on any stack.
Introduction
splitforms.com is a form backend API. You point an HTML form at https://splitforms.com/api/submit, submissions arrive in your inbox, and your dashboard shows everything submitted with full search, export, and webhook fan-out. There is no server code to write, no database to provision, and no SMTP infrastructure to maintain.
It works with anything that can submit an HTML form or make an HTTP POST: static HTML, React, Next.js (App and Pages Router), Vue, Svelte, Astro, Eleventy, Hugo, Webflow, Framer, Carrd, WordPress, plain JavaScript, server actions, edge functions — anything.
Quickstart
Three steps from zero to receiving submissions in your inbox. Total time: about 60 seconds.
YOUR_ACCESS_KEY with yours, and paste it on any page on your site:<form action="https://splitforms.com/api/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<input type="text" name="name" placeholder="Your name" required />
<input type="email" name="email" placeholder="Your email" required />
<textarea name="message" placeholder="Message" required></textarea>
<!-- honeypot — bots fill this, humans don't see it -->
<input type="checkbox" name="botcheck" style="display:none" tabindex="-1" />
<button type="submit">Send</button>
</form>/dashboard/submissions — searchable, exportable, forever.Authentication
Authentication for the submit endpoint is a single string field named access_key. It identifies which form, inbox, and account a submission belongs to. Because the key lives in client-side HTML, it is not a secret in the bearer-token sense — anyone who views your page source can see it. To stop other sites from reusing it, set an allowed-domain list on the key in your dashboard (covered below).
Generating an access key
Open Dashboard → Forms → New form. A fresh access key is generated automatically when you create a form. You can have unlimited forms; each gets its own key, its own submissions table, its own settings (subject, redirect URL, allowed domains, auto-responder, etc.).
Rotating an access key
If a key leaks (e.g. accidentally pushed to a public repo), open the form in your dashboard and click Rotate access key. The old key stops working immediately and a new one replaces it in the same form's settings. Existing submissions stay put — the key is only used for authentication on new submits.
Read-API authentication
For the read API (GET /api/submissions), you need a personal access token instead. Generate one in Dashboard → API and send it as a bearer token. Personal access tokens scope to your whole account, can be rotated independently, and never appear in client-side code.
curl https://splitforms.com/api/submissions \
-H "Authorization: Bearer YOUR_PERSONAL_ACCESS_TOKEN"Integration guides
The same endpoint, the same access key, the same fields — used from every common stack. Pick yours.
Plain HTML
The simplest possible integration. No JavaScript required. The browser's native form submission posts straight to splitforms; we either redirect the user to a thank-you page or show our default success HTML.
<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="name" placeholder="Name" required />
<input type="email" name="email" placeholder="Email" required />
<textarea name="message" placeholder="Message" required></textarea>
<input type="checkbox" name="botcheck" style="display:none" tabindex="-1" />
<button type="submit">Send</button>
</form>React (Vite / CRA)
Use fetch from a client component. The browser handles the FormData encoding for you, so the only thing to inject is the access key.
import { useState } from "react";
export default function ContactForm() {
const [status, setStatus] = useState("idle");
async function onSubmit(e) {
e.preventDefault();
setStatus("loading");
const formData = new FormData(e.target);
formData.append("access_key", import.meta.env.VITE_SPLITFORMS_KEY);
const res = await fetch("https://splitforms.com/api/submit", {
method: "POST",
headers: { Accept: "application/json" },
body: formData,
});
const json = await res.json();
setStatus(json.success ? "ok" : "err");
if (json.success) e.target.reset();
}
return (
<form onSubmit={onSubmit}>
<input type="text" name="name" required />
<input type="email" name="email" required />
<textarea name="message" required />
<input type="checkbox" name="botcheck" style={{ display: "none" }} tabIndex={-1} />
<button type="submit">Send</button>
{status === "ok" && "Thanks!"}
{status === "err" && "Error"}
</form>
);
}Next.js (App Router)
Two patterns work. For the simplest UX, use a server action — it keeps your access key on the server and gives you progressive enhancement for free. For real-time UI, use a client-component fetch.
Server action
// app/contact/actions.ts
"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 };
}// app/contact/page.tsx
import { sendContact } from "./actions";
export default function ContactPage() {
return (
<form action={sendContact}>
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">Send</button>
</form>
);
}Client component fetch
"use client";
import { useState } from "react";
export default function ContactForm() {
const [status, setStatus] = useState<"idle" | "loading" | "ok" | "err">("idle");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus("loading");
const formData = new FormData(e.currentTarget);
formData.append("access_key", process.env.NEXT_PUBLIC_SPLITFORMS_KEY!);
const res = await fetch("https://splitforms.com/api/submit", {
method: "POST",
headers: { Accept: "application/json" },
body: formData,
});
const data = await res.json();
setStatus(data.success ? "ok" : "err");
}
return (
<form onSubmit={handleSubmit}>
<input type="text" name="name" required />
<input type="email" name="email" required />
<textarea name="message" required />
<input type="checkbox" name="botcheck" style={{ display: "none" }} tabIndex={-1} />
<button type="submit">{status === "loading" ? "Sending..." : "Send"}</button>
{status === "ok" && <p>Thanks!</p>}
{status === "err" && <p>Something went wrong.</p>}
</form>
);
}Next.js (Pages Router)
Pages Router doesn't have server actions — fetch from a React component, or use an API route as a thin proxy if you need to keep the access key off the client entirely.
Direct from a page component
// pages/contact.tsx
import { useState } from "react";
export default function Contact() {
const [status, setStatus] = useState<"idle" | "ok" | "err">("idle");
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
fd.append("access_key", process.env.NEXT_PUBLIC_SPLITFORMS_KEY!);
const res = await fetch("https://splitforms.com/api/submit", {
method: "POST",
headers: { Accept: "application/json" },
body: fd,
});
const json = await res.json();
setStatus(json.success ? "ok" : "err");
}
return (
<form onSubmit={onSubmit}>
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message" required />
<input type="checkbox" name="botcheck" style={{ display: "none" }} tabIndex={-1} />
<button>Send</button>
{status === "ok" && "Thanks!"}
{status === "err" && "Error"}
</form>
);
}Through a Pages API route (keeps key server-side)
// pages/api/contact.ts
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") return res.status(405).end();
const r = await fetch("https://splitforms.com/api/submit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ access_key: process.env.SPLITFORMS_KEY, ...req.body }),
});
res.status(r.status).json(await r.json());
}Vue / Nuxt
<script setup>
import { ref } from "vue";
const status = ref("idle");
async function onSubmit(e) {
status.value = "loading";
const formData = new FormData(e.target);
formData.append("access_key", import.meta.env.VITE_SPLITFORMS_KEY);
const res = await fetch("https://splitforms.com/api/submit", {
method: "POST",
headers: { Accept: "application/json" },
body: formData,
});
const json = await res.json();
status.value = json.success ? "ok" : "err";
}
</script>
<template>
<form @submit.prevent="onSubmit">
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message" required />
<input type="checkbox" name="botcheck" style="display:none" tabindex="-1" />
<button>Send</button>
</form>
</template>Svelte / SvelteKit
In SvelteKit, prefer a form action — it gives you progressive enhancement and keeps the key server-side. For pure Svelte, use fetch from a component.
<script>
let status = "idle";
async function onSubmit(event) {
status = "loading";
const formData = new FormData(event.target);
formData.append("access_key", import.meta.env.VITE_SPLITFORMS_KEY);
const res = await fetch("https://splitforms.com/api/submit", {
method: "POST",
headers: { Accept: "application/json" },
body: formData,
});
const json = await res.json();
status = json.success ? "ok" : "err";
}
</script>
<form on:submit|preventDefault={onSubmit}>
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message" required />
<input type="checkbox" name="botcheck" style="display:none" tabindex="-1" />
<button>Send</button>
</form>Static sites (Astro, Eleventy, Hugo)
Astro pages, Eleventy templates, Hugo layouts, Jekyll sites, Pelican, Zola — anything that outputs static HTML can post directly to the endpoint without any JavaScript.
---
// src/pages/contact.astro
const ACCESS_KEY = import.meta.env.PUBLIC_SPLITFORMS_KEY;
---
<form action="https://splitforms.com/api/submit" method="POST">
<input type="hidden" name="access_key" value={ACCESS_KEY} />
<input type="hidden" name="redirect" value="https://yoursite.com/thanks" />
<input type="text" name="name" required />
<input type="email" name="email" required />
<textarea name="message" required />
<input type="checkbox" name="botcheck" style="display:none" tabindex="-1" />
<button type="submit">Send</button>
</form>Webflow
Webflow's native form element posts to its own backend by default. To redirect it to splitforms, override the form's action attribute via custom code or by editing the form element settings.
- Select your form in the Designer. In the form's settings, set Action to
https://splitforms.com/api/submitand Method toPOST. - Add an Embed element inside the form containing the hidden access key input — Webflow strips unknown fields if added at the page level:
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<input type="hidden" name="redirect" value="https://yoursite.com/thanks" />
<input type="checkbox" name="botcheck" style="display:none" tabindex="-1" />Make sure each Webflow input has a nameattribute set in the field's settings — splitforms uses field names as the keys in the email body.
Framer
Use Framer's built-in Formcomponent, then in the component's settings panel set the URL field to https://splitforms.com/api/submit and the Method to POST. Add a Hidden Field with name access_key and your value. For a redirect-after-submit, add a hidden redirect field pointing at your thanks page. For sites with a published code override, you can also just include a custom Code Component with a vanilla <form action="...">.
Carrd
Carrd has form integrations for the popular form backends. splitforms isn't one of the built-in dropdown options yet, but the Custom URL option works perfectly.
- Add a Form element to your Carrd page. In the element settings, set Type to Custom URL.
- Set Action to
https://splitforms.com/api/submitand Method to POST. - Add a Hidden Field with name
access_keyand valueYOUR_ACCESS_KEY. Add another with nameredirectand value of your thanks-page URL.
Webhooks
Push every submission to your own server, Slack, Discord, WhatsApp (via CallMeBot), Zapier, n8n, or anything that accepts an HTTP POST. We auto-detect Slack, Discord, and CallmeBot URLs and format the payload accordingly. Otherwise the body is the full submission JSON.
Configuring a webhook
Open Dashboard → Webhooks → New webhook, paste the target URL, and save. We auto-detect the flavor from the URL. Webhooks are user-scoped — every form's submissions fire every active webhook on your account.
Payload (generic)
For non-Slack, non-Discord targets we POST the following JSON body with header Content-Type: application/json and X-Splitforms-Event: submission.created.
{
"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"
}
}Signature verification
Each webhook gets a shared secret. We sign the raw request body with HMAC-SHA256 and send the signature in the X-Splitforms-Signature header in the format sha256=<hex>. Verify it on your end with a constant-time comparison to confirm the request came from splitforms and was not modified in transit:
import crypto from "node:crypto";
function verifyWebhook(rawBody, signatureHeader, secret) {
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader),
);
}
// In an Express handler — you must read the RAW body, not the parsed JSON.
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
const sig = req.header("X-Splitforms-Signature");
if (!sig || !verifyWebhook(req.body, sig, process.env.SPLITFORMS_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const payload = JSON.parse(req.body);
// ...do work with payload.submission
res.status(200).end();
});Retry behavior
Webhook delivery has an 8-second timeout. We do not automatically retry failed deliveries — instead the dashboard shows the last status and last error per webhook so you can catch problems quickly. Use Test webhook in the dashboard to fire a sample payload and confirm your endpoint is reachable. If you need durable retries, point the webhook at a queue (SQS, Upstash QStash, Inngest) that handles redelivery.
Email delivery
Notification emails are sent from splitforms.comon our outbound infrastructure with proper SPF, DKIM, and DMARC records. You don't need to configure DNS on your end — we already authenticate the sending domain so messages clear most spam filters.
Reply-To: hitting reply goes to the visitor
If your form has an email field, we automatically set Reply-To to that address. Hitting Reply in your inbox responds to the visitor, not to splitforms. To override, include a replyto field in the submission.
From name and subject
Override the From name with a from_name field in the form. Override the subject with a subject field, or set a per-form default in Settings → Email → Subject template. Subject templates support placeholders like {{name}} and {{email}}.
Deliverability tips
- Set the
email_toon the form to a real, monitored mailbox — auto-replies and out-of-office bounces hurt deliverability over time. - Add
splitforms.comto your email provider's safe-sender list if your IT department aggressively filters external mail (common on Microsoft 365 + Exchange Online). - Use linked emails (account-wide BCC) for archival to a shared inbox rather than email forwarding rules — forwarding can break SPF alignment.
- Avoid using a no-reply address as the recipient. Mail providers downrank unmonitored mailboxes.
Spam protection
Three layers of spam defense are available. The honeypot is on by default; the rest are toggles in your form settings.
Honeypot field (default)
Every template ships with a hidden botcheck honeypot. Bots fill every field they see; humans don't see this field because it's display:none. If a submission has botcheckfilled, we silently drop it and mark it spam in your dashboard — the bot still gets a 200 OK, so it doesn't learn to evade.
<input type="checkbox" name="botcheck" style="display:none" tabindex="-1" />Per-form rate limit
Each form is capped at 60 submissions per rolling 60-second window. Real users almost never hit it; scrapers hit it constantly and get HTTP 429 with a Retry-After header.
Cloudflare Turnstile / reCAPTCHA
If you want a real bot challenge on top of the honeypot, render a Cloudflare Turnstile or Google reCAPTCHA widget in your form. splitforms accepts the standard cf-turnstile-response and g-recaptcha-responsefield names — they're excluded from the saved data automatically. (Token verification against the Turnstile/reCAPTCHA API is on our roadmap; today the fields are accepted but not server-side verified, so honeypot + rate limit remain your real bot defense.)
Allowed domains
Lock your access key to specific domains so nobody can copy your key and submit forms from other sites. In the form's settings, list the allowed domains comma-separated (e.g. example.com,www.example.com). Submissions from any other Origin or Referer return HTTP 403 with Origin not allowed.
Local development from localhostis allowed regardless of the list, so you don't have to fight your own restriction during development.
Redirect after submit
Add a hidden redirect field to the form to send users to a thank-you page after a successful submit:
<input type="hidden" name="redirect" value="https://yoursite.com/thanks" />You can also set a per-form default in the dashboard. The form field, when present, overrides the dashboard default. If no redirect is configured at all and the request asks for JSON (via Accept: application/json or Content-Type: application/json), we return JSON. Otherwise we render a built-in success page.
Custom email subjects
Set the subject in the form itself with a subject field, or set a per-form default in the dashboard. Templates support placeholders like {{name}} and {{email}}.
<input type="hidden" name="subject" value="New lead from website" />Auto-responder
Send an automatic thank-you reply to every submitter. Toggle on in Settings → Auto-responder, write your subject + body, and we'll send a copy to whatever email field the user provided. The auto-responder does not fire if the submission is flagged as spam by the honeypot.
Linked emails (BCC)
Account-wide BCC. Every submission across allyour forms gets BCC'd to these addresses. Useful for keeping a teammate copied or archiving to a shared inbox. Manage in Linked emails in your dashboard.
MCP for AI agents
splitforms ships an MCP server so AI coding agents (Claude Code, Cursor, Windsurf) can read your forms, list submissions, generate template HTML, and look up your access key — all without you copy-pasting credentials. Get your MCP token from Dashboard → MCP and paste the install command into your IDE.
Plan limits
Hit the limit? Submissions still arrive but are flagged over-quotain the dashboard with HTTP 429. Upgrade or wait until next month — they aren't deleted.
How to get help
Stuck? Use these resources, in roughly the order they'll solve your problem fastest.
- Read the FAQ → — covers billing, plans, GDPR, deleting submissions, and general account questions.
- API reference → — HTTP status codes, request/response shapes, every reserved field, every error message.
- Live test form → — a working /api/submit form you can use to confirm the endpoint is up and your access key is valid.
- Email support: hello@splitforms.com — humans on the other end. We reply within one business day, usually within a few hours during EU working hours.
When emailing support, include: your account email, the access key (or last 4 chars), an approximate timestamp of the failing request, the HTTP status you got, and the response body. That cuts a back-and-forth round-trip.
Troubleshooting
I'm not receiving any emails
- Check your spam / junk / promotions folders.
- Open the dashboard. If submissions are appearing there, the API is working — the issue is on the email side. Check that
Settings → Email → Email tois set to a real, monitored mailbox. - If your email is on Microsoft 365 / Exchange Online, ask IT to safelist
@splitforms.com. Microsoft is notoriously aggressive on external mail without explicit allow-list rules. - Check if the submission is flagged as spam in the dashboard (honeypot triggered). Spam submissions don't generate notification emails.
HTTP 400 — "Missing access_key"
The access_keyfield wasn't in the POST body. Double-check the input is inside the <form> element, has name="access_key" (not id), and the value isn't empty. For JSON requests, confirm the field is at the top level of the JSON object, not nested.
HTTP 403 — "Origin not allowed"
Your access key has an allowed-domain list set, and the request's Origin / Referer doesn't match. Open the form in the dashboard, edit allowed domains, and add the missing domain (with and without www.). If you're testing from a CodePen or local file://, allowed domains will reject — temporarily clear the list while debugging.
HTTP 404 — "Invalid access_key"
The key doesn't match any form on any account. Common causes: leading/trailing whitespace pasted with the key, the key was rotated and the page is still serving the old value (clear CDN cache), or you're posting to splitforms.com with a key from a different product. Copy the key fresh from the dashboard.
HTTP 429 — "Too many submissions"
You hit the per-form limit of 60 submissions per rolling 60-second window, or your monthly quota is exhausted. Wait the Retry-After seconds (60 for the burst limit) and try again. If real users are hitting it, you almost certainly have a script retrying on every keystroke instead of on submit.
CORS error in browser console
The endpoint sends permissive CORS headers and handles OPTIONSpreflight, so CORS errors usually mean you're posting to a wrong URL (typo in /api/submit) or to http:// instead of https://. Double-check the action URL exactly matches https://splitforms.com/api/submit.
Submissions arrive but in the spam folder
Add splitforms.comto your email provider's safe-sender list. Make sure email_to is a monitored inbox — providers downrank mail to unread mailboxes over time. If the issue is consistent on a corporate domain, ask IT to allowlist our sending domain at the gateway level.
Form submits but the page redirects to a JSON response
You used a native HTML form (no fetch) without a redirect field. Either add a hidden redirect input pointing at your thanks page, set a per-form default redirect in the dashboard, or switch to a fetch-based submission with Accept: application/json so you can handle the response in JavaScript.
Honeypot is blocking real users
Some password managers and accessibility tools fill every field, including hidden ones. Audit your honeypot: it must be both display:none AND tabindex="-1", and ideally autocomplete="off". Avoid using a name like email2 or fax that 1Password might autofill. Use the literal field name botcheck for best results.
Webhook is showing "timeout" in the dashboard
Your endpoint took longer than 8 seconds to respond. splitforms doesn't auto-retry. Either speed up the endpoint (return 200 immediately, do work async), or point the webhook at a queue that handles retries (Upstash QStash, Inngest, AWS SQS).
Auto-responder is sending blank or strange emails
The auto-responder sends to whatever value is in the form'semailfield. If your form's email field is named differently (e.g. user_email, contact), the auto-responder won't fire. Rename the field to email or rely on the notification email instead.
Still stuck? Email hello@splitforms.com — we reply within a business day.
Frequently asked questions
What is the splitforms API endpoint?
Every form posts to a single URL: https://splitforms.com/api/submit. It accepts application/x-www-form-urlencoded, multipart/form-data, and application/json. Include your access_key field and any user data fields you want emailed. There is no other endpoint to learn for sending submissions.
Do I need a server or backend to use splitforms?
No. splitforms is the backend. Point a plain HTML form's action attribute at /api/submit, drop your access_key in a hidden input, and you have a working contact form. No Node server, no Lambda, no database, no SMTP setup. It also works fine with fetch from any framework if you prefer JSON.
Can I use splitforms with Next.js App Router server actions?
Yes. From a server action, fetch https://splitforms.com/api/submit with Content-Type application/json and a body containing access_key plus your fields. Keep the access key in process.env.SPLITFORMS_KEY so it never ships to the client. The endpoint replies with JSON success: true, message: "Submission received".
How do I handle the response in JavaScript?
On a successful submission, /api/submit returns HTTP 200 with { success: true, message: "Submission received" } when the request asks for JSON (Accept: application/json or Content-Type: application/json). Errors return the same shape with success: false and an explanatory message field plus an HTTP status of 400, 403, 404, 429, or 500.
Does splitforms support file uploads?
The endpoint accepts multipart/form-data, but file fields are currently not stored or attached to the notification email — only string values are persisted. If you need file uploads, post the file to your own storage (S3, R2, UploadThing) first and submit the resulting URL as a regular text field to splitforms.
Do submissions appear in my dashboard immediately?
Yes. The submission row is written synchronously before the API responds, so it shows up in /dashboard/submissions on the next reload. Email delivery runs in the background and usually arrives within seconds, but the dashboard is the source of truth — if a row is there, the submission was accepted.
How long are submissions retained?
Submissions are retained for the lifetime of your account. There is no automatic deletion, even on the Free plan. You can manually delete individual submissions or wipe a form's history from the dashboard at any time. Exporting to CSV is one click in the submissions table.
Can I migrate from Formspree, Getform, Web3Forms, or Basin?
Yes — most migrations are a single-line change. Replace the form's action URL with https://splitforms.com/api/submit and swap your old endpoint ID for a splitforms access_key in a hidden input. The honeypot, redirect, and subject conventions are intentionally compatible with the most common patterns so existing forms keep working.
Is splitforms GDPR compliant?
Submissions are stored in the EU on Supabase Postgres, transmitted over TLS, and never sold or shared. You control deletion at any time. For full DPA terms see the privacy policy at /privacy. If you need a signed DPA, email hello@splitforms.com.
What happens if I exceed my monthly submission limit?
Submissions still arrive but are flagged over-quota in the dashboard. You will not lose data. Either upgrade to Pro ($5/mo for 5,000) or the 4-Year plan ($59 for 4 years, 15,000/mo), or wait for the monthly reset on the 1st. The submit endpoint returns HTTP 429 with a clear message once the quota is exhausted.