SendBeam

Contact form backend for Cloudflare Pages

Put a working contact form on a static Cloudflare Pages site with no Pages Function, no Worker and no server: the page posts to SendBeam and the message lands in your inbox.

View as Markdown

Cloudflare Pages is a good home for a small site right up to the moment you need a contact form. A static build has nowhere to send the message. The usual answers are a Pages Function that calls an email API with a secret you now have to manage, or a third-party form service that emails you the submission. SendBeam's public form endpoint is the second kind of answer, with the difference that the same account also handles your newsletter and transactional email. This guide wires a plain HTML form on Cloudflare Pages to a SendBeam contact form, protects it with Cloudflare Turnstile and an origin allow-list, and sets the Content Security Policy header so all of it actually loads.

Prerequisites

  • A SendBeam workspace (the Free plan is enough for this guide; see billing and plans for its limits).
  • A site deployed on Cloudflare Pages, built by any static generator or by hand.
  • An address to receive messages at. It must be the email of a member of the workspace, or an address on a verified sending domain.
  • Optional: a Cloudflare Turnstile widget (free) for the bot check. You create it in the Cloudflare dashboard under Turnstile; it gives you a site key and a secret key.

Steps

  1. Create the contact form in SendBeam. Open Forms, click add, and choose Contact as the form type. Under Send messages to enter the address that should receive submissions. Save, then open the form's page and copy its ID from the API Endpoint line: it is the UUID at the end of https://sendbeam.io/api/forms/…. The default fields for a contact form are email, name, subject and message, which is what the markup below sends. The details of what a contact form does are in Creating a form.
  2. Switch on the protection you want. In the form's Protection section add your site under Allowed sites as an origin, for example https://www.example.com (scheme and host, no path; add the pages.dev preview origin too if you want to test there). If you are using Turnstile, paste the widget's site key and secret key in the same section. SendBeam stores the secret encrypted and verifies every token with Cloudflare before it reads the message.
  3. Add the form to your page. Replace YOUR_FORM_ID and, if you are using Turnstile, YOUR_TURNSTILE_SITE_KEY, and YOUR_FORM_CHECK_FIELD with the hidden field name on the form's Embed tab — every form has its own. That input is positioned off screen and must stay empty; a submission that fills it is accepted with a 200 and thrown away, so the bot learns nothing.
    <!-- contact.html -->
    <form id="contact-form" data-endpoint="https://sendbeam.io/api/forms/YOUR_FORM_ID" novalidate>
      <label for="cf-name">Name</label>
      <input id="cf-name" name="name" type="text" autocomplete="name" />
    
      <label for="cf-email">Email</label>
      <input id="cf-email" name="email" type="email" autocomplete="email" required />
    
      <label for="cf-subject">Subject</label>
      <input id="cf-subject" name="subject" type="text" />
    
      <label for="cf-message">Message</label>
      <textarea id="cf-message" name="message" rows="6" required></textarea>
    
      <!-- Hidden check field, off-screen and never filled by people. Each form has
           its own name: copy it from the form's Embed tab in SendBeam. -->
      <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>
    
      <!-- Turnstile (optional): remove this div and the script tag if you are not using it -->
      <div class="cf-turnstile" data-sitekey="YOUR_TURNSTILE_SITE_KEY" data-size="flexible"></div>
    
      <button type="submit">Send message</button>
      <p id="contact-status" role="status" aria-live="polite"></p>
    </form>
    
    <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
    <script src="/contact-form.js" defer></script>
  4. Add the script. Save this as public/contact-form.js (or wherever your generator copies static files from). It posts the fields as JSON, shows the thank-you message SendBeam returns, and resets the Turnstile widget if the submission was refused so the visitor can try again. There is no Pages Function in this setup: the browser talks to SendBeam directly and the endpoint answers with CORS headers.
    // public/contact-form.js
    (function () {
      var form = document.getElementById('contact-form');
      var status = document.getElementById('contact-status');
      if (!form || !status) return;
    
      form.addEventListener('submit', function (event) {
        event.preventDefault();
        var button = form.querySelector('button[type="submit"]');
        var tokenField = form.querySelector('[name="cf-turnstile-response"]');
    
        var payload = {
          name: form.name.value,
          email: form.email.value,
          subject: form.subject.value,
          message: form.message.value,
          YOUR_FORM_CHECK_FIELD: form.YOUR_FORM_CHECK_FIELD ? form.YOUR_FORM_CHECK_FIELD.value : '',
          turnstile_token: tokenField ? tokenField.value : ''
        };
    
        button.disabled = true;
        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.reset();
              form.querySelectorAll('input, textarea, button').forEach(function (el) { el.hidden = true; });
              status.textContent = res.json.message;
              if (res.json.redirect_url) setTimeout(function () { location.assign(res.json.redirect_url); }, 1500);
              return;
            }
            status.textContent = res.json.error || 'Something went wrong. Please email us instead.';
            if (window.turnstile) window.turnstile.reset();
          })
          .catch(function () {
            status.textContent = 'Could not reach the server. Please email us instead.';
          })
          .finally(function () { button.disabled = false; });
      });
    })();
  5. Set the Content Security Policy. If your site sends a CSP header (and a Pages site should), the Turnstile script, its iframe and the two outbound requests need allowing. Cloudflare Pages reads a _headers file from the build output; put this in public/ so it is copied to the root. Adjust the other directives to match your site; the parts that matter here are challenges.cloudflare.com in script-src, frame-src and connect-src, and sendbeam.io in connect-src.
    # public/_headers  (copied to the site root by Cloudflare Pages)
    /*
      Content-Security-Policy: default-src 'self'; script-src 'self' https://challenges.cloudflare.com; connect-src 'self' https://sendbeam.io https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; img-src 'self' data:; style-src 'self' 'unsafe-inline'
      X-Content-Type-Options: nosniff
      Referrer-Policy: strict-origin-when-cross-origin
  6. Deploy. Commit and push; Pages builds and publishes as usual. Nothing is needed in the Pages project settings, and there are no secrets to add, because the only secret in the system (the Turnstile secret) lives in SendBeam.
info
Using the Astro starter instead? The astro-cloudflare-pages starter contains this form as an Astro component, reads the form ID from PUBLIC_SENDBEAM_CONTACT_FORM_ID and ships the _headers file above. Host notes are on the Cloudflare Pages integration page.

Test it

Test on the deployed site, not on localhost: if you listed allowed sites, a request from a local origin is refused with a 403, which is the allow-list working. Fill in the form with an address you control and send it. You should see the thank-you message in place of the form, and within a few seconds an email at your Send messages to address with the subject prefixed by the form's name and the visitor as Reply-To. Press reply in your mail client and check the To field is the visitor's address.

To test the endpoint on its own, or to see what a wrong origin looks like, use curl. With Turnstile enabled this request is refused with a 403 and a turnstile code, which is also correct behaviour.

curl -i -X POST https://sendbeam.io/api/forms/YOUR_FORM_ID \
  -H "Content-Type: application/json" \
  -H "Origin: https://www.example.com" \
  -d '{"name":"Test","email":"you@example.com","subject":"Ping","message":"Hello from curl"}'

A successful submission returns:

{ "success": true, "message": "Thanks — your message has been sent. We'll reply by email.", "redirect_url": null }

Every submission, delivered or not, is recorded against the form in SendBeam, so a message is never lost if the notification email fails. If it does fail the endpoint answers 503 with an error; the script above shows that text, which is your cue to keep a plain mailto: link somewhere on the page as a fallback.

Dealing with spam

A public form has no secret by design, so the protection is layered, and it helps to know the order SendBeam applies it in, because the first layer that trips decides the response:

  • Rate limit. Too many submissions from one visitor in a short window, then a 429.
  • Allowed sites. The browser's Origin (or Referer) must be on the list. Browsers cannot forge this, so drive-by embedding stops here. A script with curl can set any header it likes, which is why the next layers exist.
  • Automated-submission checks. A post that carries the signs of a script rather than a person gets a 200 and nothing else happens. Most crude bots fail here.
  • Turnstile. When the form has a secret, a valid token is required before the message is even read. This is the layer that stops scripted abuse; it is free and invisible to most people.
  • Daily cap. A per-form daily limit, shown and adjustable in the Protection section, so a bad night cannot empty your quota.

For a personal or small business site, allowed sites plus Turnstile is the sensible setting. If you skip Turnstile you will still get the built-in submission checks and rate limits, which is enough for low-traffic pages, but expect the occasional human-typed spam message.

Why not Netlify Forms, Formspree or a Pages Function?

Netlify Forms is excellent if you are on Netlify. It is not available on Cloudflare Pages, which is the whole reason this guide exists. It also needs its data-netlify attribute present in the built HTML for detection, and the free tier stops at 100 submissions a month.

Formspree works anywhere and is quick to set up. It is priced per form and per submission, and it is a forms product: there is no list, no double opt-in and no way to email the people who wrote in later. If a contact form is all you will ever need, it is a fair choice.

A Pages Function calling an email API gives you total control and costs nothing extra. In return you own the API key as a Pages secret, the rate limiting, the bot protection, the DKIM setup for the sending domain and the maintenance. That is a reasonable trade for one site and a poor one for six.

SendBeam's limitations are worth stating too: the receiving address must belong to a workspace member or a verified domain, the public endpoint is rate-limited as described above, and the Free plan sends from a shared address until you verify your own domain.

Next steps

Starter kits

Working code for this, form and script and _headers file together, is in the starter kits. Clone the one closest to your setup, drop in your form ID and deploy:

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.