Bay 01Start here
#The basic form
Point any HTML form at your sendm8 endpoint with method="POST". Every field with a name is stored and delivered, using the names you chose. Keep your own markup, styling and validation: we don’t inject anything into your page.
There are two kinds of endpoint, and they work the same way:
/f/you@example.com: no account needed. Great for trying it out and for small sites. How it works./f/k3x9q2m7ab: a form you created in the dashboard. The id keeps your email address out of your page source, and unlocks channels, allowed domains and your own Resend key.
<form action="https://sendm8.com/f/k3x9q2m7ab" method="POST">
<label>Email <input name="email" type="email" required></label>
<label>Message <textarea name="message"></textarea></label>
<button>Send</button>
</form>export function ContactForm() {
return (
<form action="https://sendm8.com/f/k3x9q2m7ab" method="POST">
<input name="email" type="email" required />
<textarea name="message" />
<button>Send</button>
</form>
);
}<template>
<form action="https://sendm8.com/f/k3x9q2m7ab" method="POST">
<input name="email" type="email" required />
<textarea name="message" />
<button>Send</button>
</form>
</template>---
const endpoint = "https://sendm8.com/f/k3x9q2m7ab";
---
<form action={endpoint} method="POST">
<input name="email" type="email" required />
<textarea name="message" />
<button>Send</button>
</form>export default function Contact() {
// Plain HTML post: works with JavaScript off, no API route needed
return (
<form action="https://sendm8.com/f/k3x9q2m7ab" method="POST">
<input name="email" type="email" required />
<textarea name="message" />
<button>Send</button>
</form>
);
}We accept application/x-www-form-urlencoded (a normal form post), multipart/form-data (for files) and application/json. A submission can have up to 100 fields and 64 KB of text.
Bay 02No account
#No signup: email in the URL
Put your own email address at the end of the URL and the form works straight away. There’s no account to create and no password to forget. We just need to check the address is really yours before we send anything to it.
<form action="https://sendm8.com/f/you@example.com" method="POST">
<label>Your email <input name="email" type="email" required></label>
<label>Message <textarea name="message" required></textarea></label>
<button>Send</button>
</form>- 1
Someone submits the form
It might be you testing it. We store the submission safely, and the visitor sees the thank-you page as normal.
- 2
We send one confirmation email
It goes to the address in the URL and asks whether you want submissions from this form. It comes from
notify@sendm8.com. - 3
You click Confirm
The form goes live, and anything that arrived while we were waiting is delivered to you straight away.
- ✓
Every submission after that just arrives
No more confirmation emails for that address.
Zero-signup forms send email only. When you want Discord, Slack, webhooks or the dashboard inbox, sign in with GitHub or Google using the same verified email address, and your existing forms move into your account automatically.
Bay 03Formspree-compatible
#Special fields
A few field names starting with an underscore change how a submission is handled. They match Formspree’s, so if you’re moving over, changing the URL is the only edit you need. Underscore fields are used and then removed: they aren’t stored with the submission.
| Field | Also accepts | What it does |
|---|---|---|
_replyto | email | Sets Reply-To on the notification email, so hitting reply answers the person who wrote in. Must be a valid address. |
_subject | None | The subject line of the notification email. |
_next | _redirect | Where to send the visitor after a normal form post. Must be on one of the form’s allowed domains. Redirects. |
_gotcha | _honeypot | A hidden honeypot. If it has anything in it, the submission is treated as spam. Spam protection. |
_cc | None | Extra recipients, comma-separated. Only addresses you’ve verified in your account get a copy, so it can’t be used to email strangers. |
<form action="https://sendm8.com/f/k3x9q2m7ab" method="POST">
<input type="hidden" name="_subject" value="New enquiry from the website">
<input type="hidden" name="_next" value="https://yourstudio.com/thanks">
<input type="hidden" name="_cc" value="team@yourstudio.com">
<input type="text" name="_gotcha" style="display:none" tabindex="-1" autocomplete="off">
<label>Your email <input name="_replyto" type="email" required></label>
<label>Message <textarea name="message"></textarea></label>
<button>Send</button>
</form>Bay 04JSON in, JSON out
#AJAX / fetch
Want to stay on the page and show your own message? Send the form with fetch and an Accept: application/json header. Instead of a redirect you get JSON back, with a proper HTTP status code. It works with any framework, or none.
const form = document.querySelector("#contact");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const res = await fetch("https://sendm8.com/f/k3x9q2m7ab", {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
});
const result = await res.json();
if (result.ok) form.replaceWith("Thanks! It's on its way.");
else alert(result.error.message);
});<form id="contact">
<label>Your email <input name="email" type="email" required></label>
<label>Message <textarea name="message" required></textarea></label>
<button>Send</button>
</form>const res = await fetch("https://sendm8.com/f/k3x9q2m7ab", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
email: "alex@moreno.dev",
message: "Hiya! Are you free in October?",
}),
});
const result = await res.json();
// { ok: true, id: "01K53N7XQ2R8M4DE7Q2K9F4X3M" }What comes back
Success
HTTP/1.1 200 OK
Content-Type: application/json
{
"ok": true,
"id": "01K53N7XQ2R8M4DE7Q2K9F4X3M"
}Error
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
{
"ok": false,
"error": {
"code": "quota_exceeded",
"message": "This form has reached its monthly submission limit."
}
}ok is always there, so checking it is enough. The id is the submission’s id in your dashboard. On failure, error.message is a plain-English sentence you can show to your visitor as it is, and error.code (like quota_exceeded or rate_limited) stays the same for your code to check.
| Status | What it means |
|---|---|
| 200 | Stored. A 2xx always means the submission is safely saved. |
| 303 | Browser posts only: redirect to your thank-you page, or to the Turnstile challenge page. |
| 400 | We couldn’t read it: bad JSON, more than 100 fields, or a body over 64 KB without files. |
| 403 | The page it came from isn’t on the form’s allowed domains, or a Turnstile check failed. |
| 404 | No form with that id. Check for a typo in the URL. |
| 410 | The form has been disabled by its owner. |
| 413 | A file is over 5 MB, or the files add up to more than 10 MB. |
| 423 | The form is paused. Nothing is stored until it’s switched back on. |
| 429 | Too many too fast (10 a minute per visitor), or the form’s monthly cap is used up. |
Bay 05One script tag
#AJAX without writing JavaScript
Want the stay-on-the-page feel without writing the fetch yourself? Add our tiny helper script and a data-sendm8 attribute to your form. It sends the form in the background (file uploads included), disables the button while it’s sending, and shows a message when it’s done.
<script src="https://sendm8.com/s/v1.js" defer></script>
<form action="https://sendm8.com/f/k3x9q2m7ab" method="POST"
data-sendm8 data-sendm8-success="Thanks! We’ll be in touch.">
<label>Your email <input name="email" type="email" required></label>
<label>Message <textarea name="message" required></textarea></label>
<button>Send</button>
</form>- No JavaScript? Still works. The form posts the normal way and visitors land on your thank-you page, so nobody gets stuck.
data-sendm8-success: the message to show, or a selector like#thanksfor an element to reveal. The default is “Thanks! Message sent.” The form is cleared afterwards.data-sendm8-error: a selector for where errors go. Without it we add arole="alert"paragraph to the form. The text is the API’s own plain-English error, like “This form isn’t accepting submissions right now.”- Listen for
sendm8:success(event.detail.id) andsendm8:error(event.detail.message,code,status) on the form. Callevent.preventDefault()to skip the built-in message and do your own thing. - Opt a form out with
data-sendm8="off". For a manual send,window.sendm8.submit(form)returns a promise of{ ok, id, error }.
const form = document.querySelector("form[data-sendm8]");
form.addEventListener("sendm8:success", (event) => {
console.log("Stored as", event.detail.id);
});
form.addEventListener("sendm8:error", (event) => {
console.warn(event.detail.code, event.detail.message);
});Bay 06After they hit send
#Redirects & thank-you page
After a normal form post (not AJAX), sendm8 answers with a 303 redirect, so refreshing the next page never submits the form twice. Where the visitor lands is decided in this order:
- The
_nextfield in the form, if there is one. - The redirect URL in the dashboard, under Form settings → General.
- Our default thank-you page.
<form action="https://sendm8.com/f/k3x9q2m7ab" method="POST">
<input type="hidden" name="_next" value="https://yourstudio.com/thanks">
<!-- your fields -->
</form>The default thank-you page is deliberately quiet: it thanks the visitor, links back to the page they came from and carries a small “Powered by sendm8” and a report link. Your visitors see it, not you, so it doesn’t try to sell them anything. See the default thank-you page.
Bay 07Five layers, cheapest first
#Spam protection
Spam protection is on for every form. Spam is still stored, in its own folder, so you can check nothing real was caught. It’s never delivered, and it’s deleted after 30 days.
7.1 Honeypot
A field that’s hidden from people but visible to bots, which tend to fill in everything. Anything in it marks the submission as spam. Call it _gotcha, or pick your own name in Form settings → Spam.
<!-- Real people never see it. Bots fill in every field they find. -->
<input type="text" name="_gotcha" style="display:none" tabindex="-1" autocomplete="off">7.2 Rate limits and heuristics
Each visitor can send 10 submissions a minute to a form, and a form takes 120 a minute in total. Past that, they get a 429. We count by a one-way hash of the IP address and never store the address itself.
Then come the quick checks: a message stuffed with links, well-known spam phrases, throwaway email domains, a message that’s only a URL, and keyboard-mash gibberish.
7.3 Cloudflare Turnstile
Turnstile is Cloudflare’s free alternative to “click all the traffic lights”. There are two ways to use it, depending on how your form is sent.
For plain HTML forms
Challenge page
No setup. Turn it on in Form settings → Spam. When a submission looks borderline, the visitor is sent to a short check on sendm8.com/c/…. Once they pass, the submission goes through and they carry on to your thank-you page.
For AJAX forms
Bring your own Turnstile
AJAX can’t follow a redirect to a challenge, so put the widget on your own form instead. We check the token it adds with your secret key.
Bring your own Turnstile, step by step
- In the Cloudflare dashboard, open Turnstile and add a widget. Add your site’s hostname (for example
yourstudio.com). - Copy the site key into the widget on your form, as below.
- Paste the secret key into sendm8 under Form settings → Spam → Turnstile → Bring your own. It’s stored encrypted.
- That’s it. The widget adds a
cf-turnstile-responsefield, andFormDatapicks it up automatically. Submissions without a valid token get a403.
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<form action="https://sendm8.com/f/k3x9q2m7ab" method="POST">
<label>Your email <input name="email" type="email" required></label>
<label>Message <textarea name="message" required></textarea></label>
<!-- Adds a hidden cf-turnstile-response field once the check passes -->
<div class="cf-turnstile" data-sitekey="0x4AAAAAAAyour-site-key"></div>
<button>Send</button>
</form>7.4 AI spam score
Optional, and off until you switch it on in Form settings → Spam. A small model scores each message after it’s been stored, so it never slows your visitor down. Anything it’s confident about moves to spam before it’s delivered. You’ll see the score and the reasons on every submission.
7.5 Phishing guard
Bay 08Multipart
#File uploads
Add enctype="multipart/form-data" to the form and use normal file inputs. Without the enctype, browsers send only the file name, not the file.
<form action="https://sendm8.com/f/k3x9q2m7ab" method="POST" enctype="multipart/form-data">
<label>Your email <input name="email" type="email" required></label>
<label>CV (PDF) <input name="cv" type="file" accept=".pdf"></label>
<label>Screenshots <input name="screenshots" type="file" accept="image/*" multiple></label>
<button>Send</button>
</form>- Per file
- 5 MB
- Per submission
- 10 MB
- Too big
- 413
If any file is over the limit, the whole submission is refused with a 413, so nothing arrives half-finished. It’s worth putting the limit next to the input, and checking file.size in the browser if you’re using AJAX.
Files appear on the submission in your dashboard. Notification emails and channel messages include a link to download them rather than the files themselves, so nobody’s inbox fills up with attachments.
Bay 09About 2 minutes
#Connect Discord
Send every submission to a Discord channel. It uses a Discord webhook, so there’s no bot to invite and no permissions to grant. You’ll need Manage Webhooks on the server.
- In Discord, open the server menu and choose Server Settings → Integrations.
- Click Webhooks, then New Webhook.
- Give it a name (sendm8 is fine), pick the channel submissions should go to, and click Copy Webhook URL.
- In sendm8, open Form settings → Channels → Discord and paste it. It should start with
https://discord.com/api/webhooks/. - Hit Send test. A test message should appear in the channel within a couple of seconds.
What it looks like
Bay 10Unlimited email
#Connect Resend (BYOK)
Out of the box, notifications go through our shared sender, which sends up to 10 instant emails a day per account (the rest arrive in your daily digest). Add your own Resend API key and every notification goes through your Resend account instead: no daily cap from us, and it comes from your own domain.
- In Resend, go to Domains, add the domain you want to send from and add the DNS records it shows you. Wait until it says Verified.
- Go to API Keys → Create API Key. Choose Sending access (you can limit it to that one domain) and copy the key. It starts with
re_and Resend only shows it once. - In sendm8, open Account → Email, paste the key and click Save. We check it works straight away.
- Pick the From address, for example
forms@yourstudio.com, then send yourself a test.
Keys are encrypted before they’re stored and are never shown in full again: you’ll only see something like re_••••4f2a. To remove one, delete it in sendm8, and revoke it in Resend too for good measure.
Bay 11For your own code
#Webhooks
Add a webhook in Form settings → Channels → Webhook and we’ll POST every submission to your URL as JSON. It needs to be https and publicly reachable: private IP ranges and bare IP addresses are blocked. You’ll get a signing secret when you add it.
The request
POST /hooks/sendm8 HTTP/1.1
Content-Type: application/json
User-Agent: sendm8-webhooks
X-Sendm8-Signature: t=1789400527,v1=5967b4fd69839aeb4fabdbcee4b69e8e2f3f690d2602b67034f1b30382e1b79e{
"event": "submission.created",
"id": "01K53N7XQ2R8M4DE7Q2K9F4X3M",
"tracking": "SM8 7Q2K 9F4X 3M",
"created_at": "2026-09-14T15:42:07Z",
"form": { "id": "k3x9q2m7ab", "name": "Studio contact" },
"data": {
"name": "Alex Moreno",
"email": "alex@moreno.dev",
"message": "Hiya! Are you free for a quick project in October?"
},
"files": [],
"spam": { "score": 0.04, "verdict": "ok" }
}Checking the signature
Anyone can post to a public URL, so check that a request really came from us. X-Sendm8-Signature holds a Unix timestamp t and a signature v1. The signature is an HMAC-SHA256, in hex, of the timestamp, a full stop and the raw request body, using your signing secret.
import { createHmac, timingSafeEqual } from "node:crypto";
const FIVE_MINUTES = 5 * 60;
// rawBody must be the exact bytes we sent, before any JSON parsing.
export function verifySendm8(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((pair) => pair.trim().split("=")),
);
const t = Number(parts.t);
if (!t || !parts.v1) return false;
// Ignore old deliveries, so a captured request can't be replayed later
if (Math.abs(Date.now() / 1000 - t) > FIVE_MINUTES) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && timingSafeEqual(a, b);
}// Example: a fetch-style handler (Cloudflare Workers with nodejs_compat, Bun, Deno)
export default {
async fetch(request, env) {
const raw = await request.text();
const header = request.headers.get("X-Sendm8-Signature") ?? "";
if (!verifySendm8(raw, header, env.SENDM8_WEBHOOK_SECRET)) {
return new Response("Bad signature", { status: 401 });
}
const submission = JSON.parse(raw);
// ...do something with submission.data
return new Response(null, { status: 204 });
},
};Bay 12Bring your forms
#Moving from Formspree
sendm8 reads the same field names as Formspree, so a plain HTML form moves with a one-line change: the action URL. Code that sends forms with JavaScript needs a small change to how it reads errors.
Moving a form
- Get a sendm8 endpoint. Create a form in the dashboard and copy its URL, or skip signing up and use
https://sendm8.com/f/you@example.com. - Swap the URL everywhere the form posts to
https://formspree.io/f/…. - Send a test submission and check it arrives where you expect. Then add Discord, Slack, Telegram or webhooks if you like.
<form action="https://sendm8.com/f/k3x9q2m7ab" method="POST">
<input name="email" type="email" required>
<textarea name="message"></textarea>
<input type="hidden" name="_subject" value="New enquiry">
<input type="text" name="_gotcha" style="display:none">
<button>Send</button>
</form><form action="https://formspree.io/f/xyzabcde" method="POST">
<input name="email" type="email" required>
<textarea name="message"></textarea>
<input type="hidden" name="_subject" value="New enquiry">
<input type="text" name="_gotcha" style="display:none">
<button>Send</button>
</form>What maps to what
| Formspree | sendm8 |
|---|---|
https://formspree.io/f/{id} | https://sendm8.com/f/{id}, or https://sendm8.com/f/{your email} with no account |
_replyto or email, _subject, _next, _gotcha, _cc | The same. _next must be on one of the form’s allowed domains, and _cc only copies addresses verified in your account. |
| Other underscore fields | Ignored and not stored. |
JSON success: { ok: true } | { ok: true, id } |
JSON errors: an errors array | { ok: false, error: { code, message } } with a matching HTTP status. AJAX / fetch. |
@formspree/react (useForm) | A few lines of fetch, below. The package only talks to Formspree. |
@formspree/ajax and data-fs-* attributes | The helper script and data-sendm8 attributes. AJAX without writing JavaScript. |
JavaScript and React
const res = await fetch("https://sendm8.com/f/k3x9q2m7ab", {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
});
const result = await res.json();
if (result.ok) {
showThanks();
} else {
// Formspree: result.errors.map((e) => e.message)
showError(result.error.message);
}// Before: import { useForm, ValidationError } from "@formspree/react";
import { useState } from "react";
export function ContactForm() {
const [state, setState] = useState({ submitting: false, succeeded: false, error: null });
async function handleSubmit(event) {
event.preventDefault();
setState({ submitting: true, succeeded: false, error: null });
const res = await fetch("https://sendm8.com/f/k3x9q2m7ab", {
method: "POST",
body: new FormData(event.currentTarget),
headers: { Accept: "application/json" },
});
const result = await res.json();
setState({ submitting: false, succeeded: result.ok, error: result.ok ? null : result.error.message });
}
if (state.succeeded) return <p>Thanks! It's on its way.</p>;
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required />
<textarea name="message" required />
{state.error && <p role="alert">{state.error}</p>}
<button disabled={state.submitting}>Send</button>
</form>
);
}Migrating with an AI assistant
These docs are also published as Markdown for AI assistants: /llms.txt lists every section, and /llms-full.txt has all of them in one file. Create your sendm8 forms first (an assistant can’t do that for you), then give it a prompt like this in the app you’re migrating.
Migrate this app's forms from Formspree to sendm8. Read https://sendm8.com/llms-full.txt first.
Endpoint mapping (Formspree → sendm8):
- https://formspree.io/f/xyzabcde → https://sendm8.com/f/k3x9q2m7ab
1. Find every Formspree usage: "formspree.io", "@formspree/react", "@formspree/ajax",
data-fs-* attributes, and environment variables holding Formspree form IDs.
2. HTML forms: change only the action URL. Field names stay the same.
3. JavaScript submissions: POST to the sendm8 URL with "Accept: application/json".
Success is { ok: true, id }. Failure is { ok: false, error: { code, message } }:
show error.message. Update any code that reads Formspree's errors array.
4. Replace @formspree/react and @formspree/ajax with fetch (or the sendm8 helper script),
keeping the same loading, success and error states. Then remove the packages.
5. Don't change styling, copy or field names.
6. List every file you changed, and anything you couldn't migrate.