# sendm8 > sendm8 is a free, open-source form backend. Point any HTML form at a URL and get the submissions by email, Discord, Slack, Telegram or webhook. No server, no signup required. Key facts: - Endpoint: `POST https://sendm8.com/f/{formId}`, or `POST https://sendm8.com/f/{email}` with no account (the owner confirms once by email). - Send `Accept: application/json` to get JSON back. Success: `{ "ok": true, "id": "…" }`. Failure: `{ "ok": false, "error": { "code": "…", "message": "…" } }` with a 4xx or 5xx status. - Special fields match Formspree: `_replyto` (or `email`), `_subject`, `_next`, `_gotcha`, `_cc`. - Forms are created by a person in the dashboard at https://sendm8.com/app. There's no API key for creating forms. Human-readable version: https://sendm8.com/docs ## 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](https://sendm8.com/docs#zero-signup). - **`/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. **HTML: `contact.html`** ```html
``` **React: `ContactForm.jsx`** ```jsx export function ContactForm() { return ( ); } ``` **Vue: `ContactForm.vue`** ```vue ``` **Astro: `src/components/Contact.astro`** ```astro --- const endpoint = "https://sendm8.com/f/k3x9q2m7ab"; --- ``` **Next.js: `app/contact/page.tsx`** ```tsx export default function Contact() { // Plain HTML post: works with JavaScript off, no API route needed return ( ); } ``` We accept `application/x-www-form-urlencoded` (a normal form post), `multipart/form-data` (for [files](https://sendm8.com/docs#uploads)) and `application/json`. A submission can have up to 100 fields and 64 KB of text. > **Fields need a name** > > Browsers only send inputs that have a `name` attribute. If a field is missing from your submissions, that’s nearly always why. An `id` on its own isn’t enough. --- ## 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` ```html ``` 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. 4. **Every submission after that just arrives** No more confirmation emails for that address. > **One confirmation per 24 hours** > > To stop people using sendm8 to spam random inboxes, we send at most one confirmation email to an address every 24 hours, and we don’t resend it while it’s waiting. Can’t find it? Check your spam folder first. If it’s really gone, submit the form again once 24 hours have passed. Zero-signup forms send email only. When you want Discord, Slack, webhooks or the dashboard inbox, [sign in](https://sendm8.com/app/sign-in) with GitHub or Google using the same verified email address, and your existing forms move into your account automatically. --- ## 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](https://sendm8.com/docs#redirects). | | `_gotcha` | `_honeypot` | A hidden honeypot. If it has anything in it, the submission is treated as spam. [Spam protection](https://sendm8.com/docs#spam). | | `_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. | `contact.html` ```html ``` --- ## 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. **FormData: `contact.js`** ```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); }); ``` **HTML: `contact.html`** ```html ``` **JSON body: `contact.js`** ```js 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 `200 OK` ```http HTTP/1.1 200 OK Content-Type: application/json { "ok": true, "id": "01K53N7XQ2R8M4DE7Q2K9F4X3M" } ``` Error `4xx` ```http 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. | > **Sending files with fetch?** > > Pass the `FormData` as the body and don’t set a `Content-Type` header yourself. The browser adds the right multipart header, boundary included. --- ## 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` ```html ``` - **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` ```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); }); ``` > **Small and dependency-free** > > About 6 KB, no libraries, works in every browser with `fetch`. It only touches forms with `data-sendm8` whose `action` is a sendm8 `/f/` endpoint, and adds nothing to the page apart from an optional `window.sendm8`. --- ## 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` ```html ``` > **Redirects must be on an allowed domain** > > `_next` only works for addresses on one of the form’s allowed domains (**Form settings → General → Allowed domains**). If it points anywhere else, we ignore it and show the default thank-you page. This stops a sendm8 form being used as an open redirect to send people to a phishing site. 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](https://sendm8.com/thanks). --- ## 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. #### 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` ```html ``` #### 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. #### 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](https://sendm8.com/c/demo) 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` ```html ``` #### 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. #### Phishing guard > **Forms that ask for passwords are held** > > Free form backends get used to collect stolen logins. If a form has fields named like `password`, `card_number`, `cvv`, `ssn` or `seed phrase`, its submissions are held instead of delivered, and the form is flagged for review. If that’s a false alarm (say, a “forgot password” support form), the dashboard explains what to do. --- ## 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` ```html ``` - **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. --- ## 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 _Example Discord message from sendm8:_ > 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 > **Slack and Telegram work the same way** > > For Slack, create an incoming webhook for your channel and paste the `https://hooks.slack.com/…` URL. For Telegram, the dashboard walks you through making a bot and finding your chat id. Every channel has a Send test button, and none of them count towards the email limit. --- ## 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](https://resend.com) 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](https://sendm8.com/app/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. > **If your key stops working** > > If Resend rejects your key (it was revoked, or your Resend quota ran out), we don’t quietly switch you back to our sender. Notifications wait for your 6pm UTC digest instead, and a banner in the dashboard tells you what went wrong. Submissions are still stored, and Discord, Slack and webhooks carry on as normal. --- ## 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` ```http POST /hooks/sendm8 HTTP/1.1 Content-Type: application/json User-Agent: sendm8-webhooks X-Sendm8-Signature: t=1789400527,v1=5967b4fd69839aeb4fabdbcee4b69e8e2f3f690d2602b67034f1b30382e1b79e ``` `Body` ```json { "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** ```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); } ``` **Using it: `worker.js`** ```js // 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 }); }, }; ``` > **Delivery and retries** > > Reply with any `2xx` within 5 seconds. Do slow work after you’ve answered. If we get an error or a timeout, we try again after 1m, 5m, 30m, 2h, 12h: 5 more attempts in all. Each retry has a fresh timestamp and signature, so it’s worth using the submission `id` to ignore duplicates. --- ## 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](https://sendm8.com/app) 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. **After: `contact.html`** ```html ``` **Before: `contact.html`** ```html ``` #### 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](https://sendm8.com/docs#ajax). | | `@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](https://sendm8.com/docs#ajax-helper). | #### JavaScript and React **fetch: `contact.js`** ```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); } ``` **React: `ContactForm.jsx`** ```jsx // 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) returnThanks! It's on its way.
; return ( ); } ``` #### Migrating with an AI assistant These docs are also published as Markdown for AI assistants: [/llms.txt](https://sendm8.com/llms.txt) lists every section, and [/llms-full.txt](https://sendm8.com/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. ``` > **Worth knowing** > > Uploads are capped at 5 MB a file. If your Formspree forms only accept submissions from certain sites, add those domains under **Form settings → General → Allowed domains**.