SendBeam

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.

View as Markdown

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.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.
    <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>
  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.
    <!-- /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>
  6. 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.
info
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

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:

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.