SendBeam

Form to email without a backend

Make a plain HTML form on any static host send its contents to your inbox: one endpoint, one small script, no server, no API key in the page.

View as Markdown

"Form to email" is the oldest job on the web and it is still awkward on a static site. There is no PHP mail() to call, and a mailto: action opens the visitor's mail client, which half of them do not have configured. What you want is for the browser to hand the fields to something that will email them to you. SendBeam's public form endpoint is that something. This guide is the framework-free version: an HTML page, a 40-line script, and a form in SendBeam that knows where to send the message. It works on GitHub Pages, Cloudflare Pages, Netlify, Vercel, S3, a shared host, or a folder served by nginx.

How it works

You create a contact form in SendBeam and give it a destination address. That form has a public URL of the shape https://sendbeam.io/api/forms/<form-id>. Your page posts a JSON object to it. SendBeam records the submission, emails it to you from your workspace's sending address with the visitor as Reply-To, and answers with a thank-you message for the page to display. Nothing is added to your contacts, because writing in is not subscribing. The form ID is public by nature (anyone who can submit the form can see it), so there is nothing to protect in the page; abuse is handled at the endpoint, as described further down.

Prerequisites

  • A SendBeam workspace. The Free plan covers a contact form.
  • A destination address that is either a member of the workspace or on a verified sending domain.
  • A static site of any kind, hosted anywhere that serves HTML and JavaScript.

Steps

  1. Create the form. In SendBeam, open Forms, add a form, choose type Contact, and enter the receiving address under Send messages to. Set a thank-you message. Open the saved form and copy the ID from the API Endpoint line. If you prefer to skip the hand-written markup entirely, the same page offers a ready-made embed snippet; the rest of this guide is for when you want your own HTML.
  2. Write the page. The form needs an email and a message; name and subject are optional. The data-endpoint attribute is where the script posts to. Replace YOUR_FORM_ID and the fallback mailto address.
    <!doctype html>
    <html lang="en">
    <head>
      <meta charset="utf-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1" />
      <title>Contact</title>
    </head>
    <body>
      <h1>Contact</h1>
    
      <form id="contact" data-endpoint="https://sendbeam.io/api/forms/YOUR_FORM_ID" novalidate>
        <p>
          <label for="name">Your name</label><br />
          <input id="name" name="name" type="text" autocomplete="name" />
        </p>
        <p>
          <label for="email">Your email</label><br />
          <input id="email" name="email" type="email" autocomplete="email" required />
        </p>
        <p>
          <label for="message">Message</label><br />
          <textarea id="message" name="message" rows="6" required></textarea>
        </p>
    
        <!-- Hidden check field. Each form has its own name: copy it from the form's Embed tab. -->
        <div style="position:absolute;left:-9999px" aria-hidden="true">
          <label>Leave this empty <input type="text" name="YOUR_FORM_CHECK_FIELD" tabindex="-1" autocomplete="off" /></label>
        </div>
    
        <p><button type="submit">Send</button></p>
        <p id="contact-status" role="status" aria-live="polite"></p>
        <p><small>Or email <a href="mailto:hello@example.com">hello@example.com</a>.</small></p>
      </form>
    
      <script src="/sendbeam-form.js" defer></script>
    </body>
    </html>
  3. Add the script. It collects every named field into JSON, adds the render-time stamp, posts, and shows the result. It is generic: any form on the page with a data-endpoint attribute is wired up, so the newsletter form in the next step reuses it. The :has() selector used to hide the inputs on success is supported by every current browser; on an older one the inputs simply stay visible under the thank-you message.
    // sendbeam-form.js — works for any form with a data-endpoint attribute
    (function () {
    
      document.querySelectorAll('form[data-endpoint]').forEach(function (form) {
        var status = form.querySelector('[role="status"]');
    
        form.addEventListener('submit', function (event) {
          event.preventDefault();
    
          // Every named field becomes a JSON key; SendBeam ignores keys the form does not declare.
          var payload = {};
          new FormData(form).forEach(function (value, key) { payload[key] = value; });
          // Turnstile, when present on the page, adds this hidden field; SendBeam reads it as the token.
          if (payload['cf-turnstile-response']) payload.turnstile_token = payload['cf-turnstile-response'];
    
          var button = form.querySelector('button[type="submit"]');
          button.disabled = true;
          if (status) status.textContent = 'Sending…';
    
          fetch(form.dataset.endpoint, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(payload)
          })
            .then(function (res) { return res.json().then(function (json) { return { ok: res.ok, json: json }; }); })
            .then(function (res) {
              if (res.ok && res.json.success) {
                form.querySelectorAll('p:not(:has([role="status"]))').forEach(function (p) { p.hidden = true; });
                if (status) status.textContent = res.json.message;
                if (res.json.redirect_url) setTimeout(function () { location.assign(res.json.redirect_url); }, 1500);
              } else {
                if (status) status.textContent = res.json.error || 'Something went wrong.';
                if (window.turnstile) window.turnstile.reset();
              }
            })
            .catch(function () { if (status) status.textContent = 'Could not reach the server. Please use the email link below.'; })
            .finally(function () { button.disabled = false; });
        });
      });
    })();
  4. Keep a fallback. The endpoint accepts JSON only, so a form cannot post to it without JavaScript. A mailto: link beside the form costs nothing and covers the rare visitor with scripts off, as well as the rare 503 when a notification cannot be sent.
    <!-- Progressive enhancement is not available: the endpoint accepts JSON only.
         Keep a mailto: link near the form so a visitor without JavaScript still has a route to you. -->
    <p>Or email <a href="mailto:hello@example.com">hello@example.com</a>.</p>
  5. Optionally, add a signup form with the same script. Create a second form in SendBeam of type Signup, pointed at a list, and use its ID. The script does not change.
    <form data-endpoint="https://sendbeam.io/api/forms/YOUR_SIGNUP_FORM_ID" novalidate>
      <p>
        <label for="sub-email">Email</label><br />
        <input id="sub-email" name="email" type="email" autocomplete="email" required />
      </p>
      <!-- Hidden check field. Each form has its own name: copy it from the form's Embed tab. -->
      <div style="position:absolute;left:-9999px" aria-hidden="true">
        <label>Leave this empty <input type="text" name="YOUR_FORM_CHECK_FIELD" tabindex="-1" autocomplete="off" /></label>
      </div>
      <p><button type="submit">Subscribe</button></p>
      <p role="status" aria-live="polite"></p>
    </form>
  6. Deploy and set Allowed sites. Upload the files. Then, in the form's Protection section in SendBeam, add your site's origin (for example https://example.com; add https://www.example.com too if both resolve). If your host sends a Content Security Policy header, add https://sendbeam.io to connect-src.

Test it

Open the deployed page and send yourself a message. You should see the thank-you text replace the form, and an email arrive at the destination address whose subject starts with the form's name and whose Reply-To is the address you typed. If you want to see the raw responses, curl is quicker than the browser:

curl -s -X POST https://sendbeam.io/api/forms/YOUR_FORM_ID \
  -H "Content-Type: application/json" \
  -d '{"name":"Test","email":"you@example.com","message":"Testing the form"}'
# {"success":true,"message":"Thanks — your message has been sent. We'll reply by email.","redirect_url":null}

The errors you are most likely to meet while setting up, and what they mean:

# Missing message on a contact form
# {"error":"A message is required"}                       HTTP 400
# Wrong origin when Allowed sites is set
# {"error":"This form does not accept submissions from this site."}   HTTP 403
# Too many submissions from one visitor in a short window
# {"error":"Too many submissions. Please try again later."}           HTTP 429
# Notification could not be delivered
# {"error":"Your message could not be sent right now. Please try again or email us directly."}   HTTP 503

Each submission is also listed against the form in SendBeam, with whether the notification was delivered, so you can check there if an email seems to have gone missing.

Dealing with spam

Because there is no secret in the page, the endpoint protects itself in layers. The hidden field in the markup above is one of them: a submission carrying the signs of a script rather than a person is discarded. On top of that, every form is rate-limited per visitor and capped per form per day, with the caps shown and adjustable on the form's settings page. For anything more you switch on two settings in the form's Protection section. Allowed sites makes SendBeam check the browser's Origin header against the origins you list; browsers cannot forge it, so it ends drive-by embedding, though a script can still fake it. Cloudflare Turnstile closes that gap: add Cloudflare's script tag and a <div class="cf-turnstile" data-sitekey="…"> inside the form, paste the site key and secret into SendBeam, and every submission must carry a valid token or it is refused with a 403 before the message is read. The script above already forwards the token when the widget is present. Turnstile needs https://challenges.cloudflare.com allowed in script-src, frame-src and connect-src if you have a CSP. See the Cloudflare Pages guide for a complete Turnstile setup with a header file.

Why not Formspree, Netlify Forms or Web3Forms?

All three do form-to-email well and you would not be wrong to pick one. Netlify Forms is the least effort if you host on Netlify and stay under 100 submissions a month; it does not exist anywhere else and detects forms at build time from the HTML, which occasionally surprises people using client-side frameworks. Formspree is host-independent and mature, priced per form and per submission. Web3Forms is the closest in spirit to this guide: an access key in the page, JSON or form-encoded posts, and a generous free tier.

What none of them do is anything after the email. If the same site also has a newsletter, or you want a welcome sequence, or you run four sites and want their forms, subscribers and sending domains in one account, that is the case for SendBeam. If a message in your inbox is the whole requirement, a forms-only service is the smaller and simpler tool. SendBeam's own constraints are the ones above: a JSON-only endpoint, the destination address rule, and the rate limits.

Next steps

Starter kits

If you would rather start from something that already runs, the starter kits have this form wired up. The plain HTML one is this guide with nothing added to it:

Every starter covers the same five things — newsletter signup, contact form, double opt-in, unsubscribe and spam protection — reads its form IDs from environment variables or site config, and is MIT licensed. Browse them all in sendbeam-starters.

Stuck, or found a gap? Ask in the community — questions, tips and every release note, with this page as the source of truth.