Double opt-in on a static site
Collect confirmed newsletter subscribers from a page with no backend: what happens after the POST, what the visitor sees, and how to word the page so people actually confirm.
Double opt-in sounds like something that needs a server: a pending state, a signed token, an email with a link, a page that flips the state when the link is clicked. On a static site you have none of that, and the temptation is to skip it. You do not have to. When a static page posts a signup to SendBeam, the confirmation flow runs entirely on SendBeam's side; your page's only job is to tell the visitor to look in their inbox. This guide shows the whole path from POST to confirmed member, how to set the list up, and how to test each step, so that you know exactly what your subscribers experience.
What double opt-in changes
Double opt-in is a property of the list, not of the form. When a form adds a contact
to a list that requires it, the contact is created and joined to the list as
unconfirmed, and SendBeam sends a confirmation email from your workspace's
sender with the subject "Please confirm your subscription to list name" and a single
button. The button's link points at https://sendbeam.io/api/confirm-optin with a
signed token bound to that contact and list. When it is clicked the membership becomes
confirmed, any list joined automation runs, and from then on campaigns to the list
reach the person. Until then they do not. On the Free plan every list behaves
this way whether or not the toggle is on; on paid plans you choose per list. The detailed
behaviour is in Lists → Double opt-in.
Prerequisites
- A SendBeam workspace and a list. On paid plans, turn on Double opt-in when you create or edit the list.
- A signup form pointed at that list. The Astro guide and the plain HTML guide both produce one; the steps below use plain HTML for brevity.
- Ideally, a verified sending domain. The confirmation email comes from your default sender, and people are more likely to click a button from
news@yourbrand.comthan from a shared address.
Steps
- Make the list double opt-in. Under Lists, create the list (or open it) and switch on Double opt-in. The Lists table shows which lists require confirmation. Nothing about the form changes when you do this: the form simply inherits it.
- Write the thank-you message for the inbox step. On the form in SendBeam, set
the Thank You Message to something like "Almost there. We have emailed you a
confirmation link; open it to finish subscribing." This string is what the endpoint returns as
message, so the page shows the right instruction without knowing anything about the list. Optionally set a Redirect URL to a dedicated thanks page, which gives you room for the spam-folder advice. - Put the form on the page. There is nothing double-opt-in-specific in the
markup; that is the point.
<form id="signup" data-endpoint="https://sendbeam.io/api/forms/YOUR_FORM_ID" novalidate> <label for="signup-email">Email</label> <input id="signup-email" name="email" type="email" autocomplete="email" required /> <!-- 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> <button type="submit">Subscribe</button> <p id="signup-status" role="status" aria-live="polite"></p> </form> <script src="/signup.js" defer></script> - Show the message SendBeam returns. The script posts, then replaces the form
with
message. If you set a redirect URL, it follows it after a moment.// signup.js (function () { var form = document.getElementById('signup'); var status = document.getElementById('signup-status'); form.addEventListener('submit', function (event) { event.preventDefault(); var email = form.email.value; fetch(form.dataset.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email, YOUR_FORM_CHECK_FIELD: form.YOUR_FORM_CHECK_FIELD.value }) }) .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.email.hidden = true; form.querySelector('label').hidden = true; form.querySelector('button').hidden = true; // The message comes from the form's settings in SendBeam. For a double opt-in list, // write it there as "Check your inbox for a confirmation email" — the page does not // need to know whether the list is single or double opt-in. status.textContent = res.json.message; if (res.json.redirect_url) setTimeout(function () { location.assign(res.json.redirect_url); }, 1500); } else { status.textContent = res.json.error || 'Something went wrong. Please try again.'; } }) .catch(function () { status.textContent = 'Could not reach the server. Please try again.'; }); }); })(); - Optionally, add a thanks page. Use it as the form's redirect URL. It is the
right place for the two things that recover most lost confirmations: check spam, and the
ten-minute resend rule.
<!-- /thanks.html — used as the form's Redirect URL, so the instruction is impossible to miss --> <h1>One more step</h1> <p>We have sent a confirmation email. Open it and press the button to finish subscribing.</p> <p>Nothing arrived? Check your spam folder, and make sure you typed the address correctly. You can submit the form again; we send at most one confirmation every ten minutes.</p> - Add a welcome automation, if you want one. Create an automation on the list joined trigger for this list. Under double opt-in it fires on confirmation, not on submission, so the welcome email only ever goes to people who proved they own the address.
POST /api/v1/lists/{id}/contacts creates a pending membership and
sends the confirmation; the response says so.
# Adding to a double opt-in list through the API behaves the same way:
curl -s -X POST https://sendbeam.io/api/v1/lists/LIST_ID/contacts \
-H "x-api-key: $SENDBEAM_API_KEY" \
-H "Content-Type: application/json" \
-d '{"contact_id":"0f8c6d2e-…"}'
# 201 → {"list_contact":{…,"confirmed":false},"double_optin_sent":true,"membership":"pending_confirmation","already_member":false} Test it
Walk the path once with an address you control, and watch SendBeam at each step.
- Submit the form on the deployed page. The thank-you message (or redirect) appears.
- In Contacts, the address exists with source form. In the list's members, it shows as unconfirmed.
- The confirmation email arrives from your default sender. Note how it looks in a real inbox: this is the moment most people decide whether to click.
- Click the button. The confirmation page loads, and the membership in SendBeam flips to confirmed. If you created a welcome automation, its first step runs now.
- Submit the form again with the same address. No duplicate contact is created, and a second confirmation is not sent within ten minutes of the first. After ten minutes, a re-submission sends a fresh one, which is the "did not arrive" path.
- Click the link a second time. It is single-use: an already-used or expired link shows an error page rather than re-confirming, which is what you want from a link that gets forwarded.
Each confirmation email counts towards the monthly email allowance, so an address that never confirms costs you at most one email per ten minutes of someone's persistence, and usually exactly one.
Dealing with spam and list-bombing
Double opt-in is itself the main protection for a signup form. A bot that submits ten thousand strangers' addresses creates ten thousand unconfirmed memberships and nothing else: none of those people will ever receive a campaign, and the workspace's reputation is untouched because no marketing email was sent to anyone who did not ask. List-bombing, where one victim's address is fed to thousands of forms across the web, is blunted by the confirmation throttle (one confirmation email per address, however many submissions) and by the endpoint's ordinary layers: per-visitor rate limits and a daily cap per form, the automated-submission checks, Allowed sites, and Cloudflare Turnstile when a secret is set. Addresses that previously bounced or complained are accepted with the normal thank-you and then ignored entirely: no contact, no membership, no email. For a public signup form, allowed sites plus Turnstile is the sensible setting; both are switched on in the form's Protection section and need no change to the page beyond the Turnstile widget.
Why not single opt-in, or a custom confirmation flow?
Single opt-in converts better on the day, and on paid plans SendBeam lets you choose it per list. Its cost arrives later: typos and fake addresses bounce, people who forgot they subscribed hit "report spam", and each of those chips at the reputation of the domain you send everything from. For a list grown from a public page, confirmation is worth the drop-off. For a list of existing customers imported from your own records, single opt-in can be the right call, and you can run both kinds of list in one workspace.
A hand-rolled confirmation flow (pending table, token, email, endpoint) is a weekend's work and a permanent maintenance item, and it only exists so that a static site can stay static. Since the flow has to live on a server somewhere, letting SendBeam be that server is the smaller system. What you give up is control over the confirmation email's exact design: it is a plain, single-button message sent from your sender, and the confirmation page is hosted on sendbeam.io. Both mention your list by name, not SendBeam's branding, except for the small "Sent with SendBeam" footer on the Free plan.
Whichever you choose, the mail that follows carries the RFC 8058 one-click unsubscribe headers and a signed unsubscribe link, so a subscriber can always leave as easily as they joined.
Next steps
- Build the form for your framework: Astro, or the integrations pages for Next.js, Hugo, Eleventy and WordPress, and the starter kits.
- Send the confirmation from your own domain: Verify a sending domain with Cloudflare DNS.
- Export confirmed members with their subscription data from Contacts → Export when you need a consent record.
Starter kits
Every starter has the signup side of this flow wired end to end, including the pending state and the wording that follows the POST, so you can watch a confirmation happen before you write your own:
- 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
- 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
- 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
- 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
- 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
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.