# 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](https://sendbeam.io/docs/lists/double-optin).

## 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](https://sendbeam.io/docs/guides/newsletter-signup-astro) and the [plain HTML guide](https://sendbeam.io/docs/guides/form-to-email-without-backend) both produce one; the steps below use plain HTML for brevity.
- Ideally, a [verified sending domain](https://sendbeam.io/docs/getting-started/sending-domain). The confirmation email comes from your default sender, and people are more likely to click a button from `news@yourbrand.com` than from a shared address.

## Steps

1. **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.
2. **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.
3. **Put the form on the page.** There is nothing double-opt-in-specific in the
  markup; that is the point.
  `
  Email
  
  
  
  
  Leave this empty
  
  
  Subscribe
  
  
  
  `
4. **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.'; });
  });
  })();`
5. **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.
  `
  One more step
  We have sent a confirmation email. Open it and press the button to finish subscribing.
  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.`
6. **Add a welcome automation, if you want one.** Create an
  [automation](https://sendbeam.io/docs/automations/triggers) 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.

> **Through the API it is the same flow.** Adding a contact to a double opt-in list
> with `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.

1. Submit the form on the deployed page. The thank-you message (or redirect) appears.
2. In **Contacts**, the address exists with source *form*. In the list's members, it shows as *unconfirmed*.
3. 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.
4. 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.
5. 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.
6. 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](https://sendbeam.io/docs/guides/newsletter-signup-astro), or the [integrations pages](https://sendbeam.io/integrations) for Next.js, Hugo, Eleventy and WordPress, and the [starter kits](https://github.com/sendbeam-io/sendbeam-starters).
- Send the confirmation from your own domain: [Verify a sending domain with Cloudflare DNS](https://sendbeam.io/docs/guides/sending-domain-cloudflare-dns).
- Export confirmed members with their subscription data from [Contacts → Export](https://sendbeam.io/docs/contacts/exporting) 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](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)
- 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)
- 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)

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/double-opt-in-static-site
