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

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](https://sendbeam.io/docs/admin/billing) 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](https://sendbeam.io/docs/getting-started/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](https://sendbeam.io/docs/forms/creating).
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.
  `
  
  Name
  
  
  Email
  
  
  Subject
  
  
  Message
  
  
  
  
  Leave this empty
  
  
  
  
  
  Send message
  
  
  
  
  `
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.

> **Using the Astro starter instead?** The
> [astro-cloudflare-pages starter](https://github.com/sendbeam-io/sendbeam-starters/tree/main/astro-cloudflare-pages)
> 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](https://sendbeam.io/integrations/cloudflare-pages).
> 

## 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

- Add a newsletter signup to the same site: [Newsletter signup on an Astro site](https://sendbeam.io/docs/guides/newsletter-signup-astro) or the plain HTML version in [Form to email without a backend](https://sendbeam.io/docs/guides/form-to-email-without-backend).
- Send from your own domain so notifications and newsletters carry your name: [Verify a sending domain with Cloudflare DNS](https://sendbeam.io/docs/guides/sending-domain-cloudflare-dns).
- The full endpoint contract, including every error, is in the [API reference](https://sendbeam.io/docs/api) under Forms.

## 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:

- HTML and vanilla JS [Plain HTML No API key needed · Any static host One page, one script, no build step: paste the files onto any host and both forms work. sendbeam-starters/plain-html](https://github.com/sendbeam-io/sendbeam-starters/tree/main/plain-html)
- Astro [Astro on Cloudflare Pages No API key needed · Cloudflare Pages Signup and contact components, form IDs in PUBLIC_ environment variables, and a _headers file that lets Turnstile load. sendbeam-starters/astro-cloudflare-pages](https://github.com/sendbeam-io/sendbeam-starters/tree/main/astro-cloudflare-pages)
- Eleventy [Eleventy on Netlify No API key needed · Netlify Nunjucks includes and a global data file, with a netlify.toml that sets the Content-Security-Policy for you. sendbeam-starters/eleventy-netlify](https://github.com/sendbeam-io/sendbeam-starters/tree/main/eleventy-netlify)
- Next.js (App Router) [Next.js on Vercel No API key needed · Vercel Client components that post straight to the form endpoint, so no route handler and no secret sit in the middle. sendbeam-starters/nextjs-vercel](https://github.com/sendbeam-io/sendbeam-starters/tree/main/nextjs-vercel)
- Hugo [Hugo No API key needed · Any static host Two partials and one script in static/, with the form IDs read from site params in hugo.toml. sendbeam-starters/hugo](https://github.com/sendbeam-io/sendbeam-starters/tree/main/hugo)

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](https://github.com/sendbeam-io/sendbeam-starters).

---
Source: https://sendbeam.io/docs/guides/contact-form-cloudflare-pages
