Get an endpoint Sign in

DocsQuickstart · about 12 minutes to read · 1 minute to do

From form
to inbox.

sendm8 is a URL you put in your form’s action. Everything on this page is optional extras. If you only read one thing, read the first section.

The short version

<form action="https://sendm8.com/f/you@example.com" method="POST">

Swap in your email, submit once, click the link we send you. Done.

Using an AI assistant? Give it sendm8.com/llms.txt: these docs as Markdown.

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

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.

contact.html
<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. 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. 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. 3

    You click Confirm

    The form goes live, and anything that arrived while we were waiting is delivered to you straight away.

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

Special field names
FieldAlso acceptsWhat it does
_replytoemailSets Reply-To on the notification email, so hitting reply answers the person who wrote in. Must be a valid address.
_subjectNoneThe subject line of the notification email.
_next_redirectWhere to send the visitor after a normal form post. Must be on one of the form’s allowed domains. Redirects.
_gotcha_honeypotA hidden honeypot. If it has anything in it, the submission is treated as spam. Spam protection.
_ccNoneExtra recipients, comma-separated. Only addresses you’ve verified in your account get a copy, so it can’t be used to email strangers.
contact.html
<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.

contact.js
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);
});

What comes back

Success

200 OK
HTTP/1.1 200 OK
Content-Type: application/json

{
  "ok": true,
  "id": "01K53N7XQ2R8M4DE7Q2K9F4X3M"
}

Error

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

HTTP status codes
StatusWhat it means
200Stored. A 2xx always means the submission is safely saved.
303Browser posts only: redirect to your thank-you page, or to the Turnstile challenge page.
400We couldn’t read it: bad JSON, more than 100 fields, or a body over 64 KB without files.
403The page it came from isn’t on the form’s allowed domains, or a Turnstile check failed.
404No form with that id. Check for a typo in the URL.
410The form has been disabled by its owner.
413A file is over 5 MB, or the files add up to more than 10 MB.
423The form is paused. Nothing is stored until it’s switched back on.
429Too 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.

contact.html
<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 #thanks for 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 a role="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) and sendm8:error (event.detail.message, code, status) on the form. Call event.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 }.
optional.js
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:

  1. The _next field in the form, if there is one.
  2. The redirect URL in the dashboard, under Form settings → General.
  3. Our default thank-you page.
contact.html
<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.

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

See the challenge 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

  1. In the Cloudflare dashboard, open Turnstile and add a widget. Add your site’s hostname (for example yourstudio.com).
  2. Copy the site key into the widget on your form, as below.
  3. Paste the secret key into sendm8 under Form settings → Spam → Turnstile → Bring your own. It’s stored encrypted.
  4. That’s it. The widget adds a cf-turnstile-response field, and FormData picks it up automatically. Submissions without a valid token get a 403.
contact.html
<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.

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

  1. In Discord, open the server menu and choose Server Settings → Integrations.
  2. Click Webhooks, then New Webhook.
  3. Give it a name (sendm8 is fine), pick the channel submissions should go to, and click Copy Webhook URL.
  4. In sendm8, open Form settings → Channels → Discord and paste it. It should start with https://discord.com/api/webhooks/.
  5. Hit Send test. A test message should appear in the channel within a couple of seconds.

What it looks like

sendm8APPToday at 16:42

Studio contact · new submission

Alex Moreno wrote in

name
Alex Moreno
email
alex@moreno.dev
message
Hiya! Loved the portfolio. Are you free for a quick project in October?

SM8 7Q2K 9F4X 3M · Spam score 0.04 · View in dashboard

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.

  1. 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.
  2. 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.
  3. In sendm8, open Account → Email, paste the key and click Save. We check it works straight away.
  4. 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

Headers
POST /hooks/sendm8 HTTP/1.1
Content-Type: application/json
User-Agent: sendm8-webhooks
X-Sendm8-Signature: t=1789400527,v1=5967b4fd69839aeb4fabdbcee4b69e8e2f3f690d2602b67034f1b30382e1b79e
Body
{
  "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.

verify.js
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);
}

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

  1. 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.
  2. Swap the URL everywhere the form posts to https://formspree.io/f/….
  3. Send a test submission and check it arrives where you expect. Then add Discord, Slack, Telegram or webhooks if you like.
contact.html
<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>

What maps to what

Formspree features and their sendm8 equivalents
Formspreesendm8
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, _ccThe same. _next must be on one of the form’s allowed domains, and _cc only copies addresses verified in your account.
Other underscore fieldsIgnored 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-* attributesThe helper script and data-sendm8 attributes. AJAX without writing JavaScript.

JavaScript and React

contact.js
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);
}

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.

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