If you'd rather keep a form you've already designed, your website can post its answers straight to a form in 53°. The responses land in your inbox like any other, and your form's automations run on them.
Our own form page and embed check for bots before they take a response. A post from your own page can't carry that check, so it's worth adding a few protections of your own. This article shows how.
You need to be able to change the form. See Who can see a form.
The form needs to be Live.
You'll need to edit your website's HTML. For the bot check, you'll also need somewhere to run a small piece of server code.
If the embed would do, use that. It checks for bots, takes file uploads and needs no code. See Share or embed a form.
Open the form and choose Share.
Open the Your own form tab and turn on Take posts from my own website.
Copy the Post to address. For a secret form, the address includes its key.
Note each field's Key. You'll find it at the bottom of each field on the Build tab. An answer is posted under its field's key.
Starting markup on the same tab gives you a plain form with an input for each field, which you can style as you like.
This form has three fields, with the keys name, email and message. Replace the address with your own Post to address.
<form id="contact" action="https://YOUR-POST-TO-ADDRESS" method="post">
<label>Name <input name="name" required></label>
<label>Email <input name="email" type="email" required></label>
<label>Message <textarea name="message"></textarea></label>
<!-- Not for people. Leave it empty and out of sight. -->
<input name="fax_number" tabindex="-1" autocomplete="off" aria-hidden="true"
style="position:absolute;left:-9999px">
<button>Send</button>
<p id="contact-status" role="status"></p>
</form>
<script>
const form = document.getElementById("contact")
const status = document.getElementById("contact-status")
form.addEventListener("submit", async (event) => {
event.preventDefault()
status.textContent = "Sending…"
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
})
const result = await response.json()
if (result.ok) {
form.reset()
status.textContent = "Thanks. Your message has been sent."
return
}
// `issues` names the answers that need another look, by field key.
const first = result.issues && result.issues[0]
status.textContent = first
? `${first.key}: ${first.message}`
: result.message
} catch {
status.textContent = "That didn't send. Check your connection and try again."
}
})
</script>
Without the script, the form still works: the visitor is taken to the page you set under After sending, or to a plain page saying their response has been sent.
Name each input after its field's key.
For a field with options, send the option's label. For Multiple choice, send the name once for each option chosen.
For a Tick box or Switch, send on, true, yes or 1 for on.
For a Rating, send its number, starting from 1. A thumbs down is 1 and a thumbs up is 2. Faces run from 1 to 5.
For an Address, send key.line1, key.line2, key.city, key.region, key.postcode and key.country, using the field's key.
Files can't be sent this way. Use the form's own page or its embed for a File upload field.
You can also post JSON: an object with each key and its answer, and a Content-Type of application/json.
A form on a public page will be found by scripts. We recommend all three of these.
The fax_number input in the example is a honeypot. It's moved off the screen, so people never see it and never fill it in. Many scripts fill in every field they find, and it's named like a real field so they don't skip it. A post with anything in that field is quietly dropped: the script is told it worked, and nothing is stored.
Keep it in your form, keep it out of sight and leave it empty. Don't use fax_number as the key of one of your own fields.
Turnstile is Cloudflare's free bot check. It usually runs without asking your visitor to do anything.
A bot check only protects you if the token it produces is verified with your secret key, and that has to happen on a server, never in the page. So with Turnstile, your form posts to your own server first. Your server checks the token, then passes the answers on to 53°.
In your Cloudflare dashboard, add a Turnstile widget for your website. You'll get a site key and a secret key.
Add the widget to your form. It adds a hidden cf-turnstile-response input for you:
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<form id="contact" action="/api/contact" method="post">
<!-- your inputs, and the fax_number input, as above -->
<div class="cf-turnstile" data-sitekey="YOUR-SITE-KEY"></div>
<button>Send</button>
</form>
On your server, verify the token before passing the answers on. This example is a handler for any JavaScript runtime that supports fetch, such as a serverless function:
const POST_TO = "https://YOUR-POST-TO-ADDRESS"
const TURNSTILE_SECRET = process.env.TURNSTILE_SECRET_KEY
export async function POST(request) {
const fields = await request.formData()
// 1. The honeypot: say it worked, and send nothing on.
if (fields.get("fax_number")) {
return Response.json({ ok: true })
}
// 2. The bot check, verified with your secret key.
const check = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
body: new URLSearchParams({
secret: TURNSTILE_SECRET,
response: fields.get("cf-turnstile-response") ?? "",
}),
}
)
const verdict = await check.json()
if (!verdict.success) {
return Response.json(
{ ok: false, message: "We couldn't confirm you're not a robot. Try again." },
{ status: 403 }
)
}
// 3. Pass the answers on, without the token.
fields.delete("cf-turnstile-response")
const sent = await fetch(POST_TO, {
method: "POST",
headers: { Accept: "application/json" },
body: fields,
})
return Response.json(await sent.json(), { status: sent.status })
}
Keep the secret key on your server, in an environment variable. Never put it in your page.
A rate limit stops one visitor, or one script, from sending hundreds of responses. If your form posts through your own server, add the limit there, before the bot check. This is the simplest version, which remembers each address for a minute:
const WINDOW_MS = 60 * 1000
const MOST_PER_WINDOW = 5
const recent = new Map()
function allowed(address) {
const now = Date.now()
const times = (recent.get(address) ?? []).filter((at) => now - at < WINDOW_MS)
if (times.length >= MOST_PER_WINDOW) {
return false
}
recent.set(address, [...times, now])
return true
}
Call allowed with the visitor's address at the top of your handler, and answer with a 429 status when it returns false. A limit held in memory like this one resets when your server restarts and isn't shared between servers. If your site runs on several, use your host's rate limiting or a shared store instead.
53° also limits how quickly responses can arrive from one address, and how many one form takes in an hour. When a limit is reached, the post is turned away with a 429 status and a message asking the visitor to try again in a few minutes. If your server passes posts on, they all arrive from your server's address, so a busy form can reach that limit sooner.
If the answers aren't right, the post is turned away with a 422 status. The reply's issues list names each field by its key, with what needs fixing.
A form that has stopped taking responses answers with a 410 status.
Your form's privacy policy switch isn't shown on your own page. If you need agreement to your privacy policy, add it to your own form.
For a secret form, keep its Post to address on your server if you can. Anyone who can read your page's source can read an address in it.
Each response counts towards your plan's monthly allowance, however it arrives. See Plans and billing.