Newsletter signup form

Newsletter signup form6 min read

Double opt-in for a newsletter form you built yourself

Ikiform doesn't send confirmation emails, so double opt-in runs in your email tool: a relay adds each signup as unconfirmed and the tool sends the link.

Preet SutharFounder, Ikiform

Ikiform never emails the person who signs up, so the confirmation email has to come from the tool that sends your newsletter. A relay receives Ikiform's signed webhook and adds the reader to that tool as unconfirmed, the tool emails them a link, and only a click makes them a subscriber. Each tool switches this on differently, and the relay should never send a second confirmation to someone who is already subscribed.

What's different here

Ikiform doesn't email the person who fills in the form. Notification emails go to you and your team only, so there is no autoresponder, confirmation email or double opt-in. Send those from your email tool, triggered by a webhook. With a provider's embed, the confirmation is a setting on the provider's form. With your own form, two systems are involved, and the confirmation belongs to the one that keeps the list. The work is making sure each signup reaches that tool in a state that triggers its confirmation, and doesn't trigger one when it shouldn't.

Double opt-in is a state machine inside the email tool. The reader starts unconfirmed, cannot be sent issues, and becomes a subscriber only when they click. Ikiform's part ends when the relay answers 204.

The life of a double opt-in signup, from form to confirmed subscriberformconsent tickedIkiformstores, signswebhookrelayadds asunconfirmedemail toolsends the linkconfirmedgets everyissueno clickstaysunconfirmed,no issuespostwebhookAPIclickThe life of a double opt-in signup, from form to confirmed subscriberformconsent tickedIkiformstores, signs webhookrelayadds as unconfirmedemail toolsends the linkconfirmedgets everyissueno clickstaysunconfirmed,no issuespostwebhookAPIclick
  1. The reader ticks the consent box and submits your form, which posts to Ikiform.
  2. Ikiform stores the signup and sends a signed webhook to your relay.
  3. The relay verifies the signature and adds the reader to the email tool as unconfirmed.
  4. The email tool emails the reader a confirmation link.
  5. If they click, the tool marks them confirmed and sends them every issue.
  6. If they never click, they stay unconfirmed and receive no newsletter.
Everything after the relay happens in the email tool, including the confirmation email's wording, the page the link opens and reminders.

The switch in each tool

Every tool keeps its own idea of unconfirmed, and its own way of turning a new API subscriber into one. Get this wrong and subscribers are added as already confirmed, with no email at all:

ToolHow an API signup gets a confirmation emailThe trap
KitCreate the subscriber with state: "inactive", then add them to a form whose incentive email is on. Kit's bulk form docs say this sends the incentive email; test the single endpoint with an address you ownCreate subscriber defaults to state: "active", and the state can't be changed afterwards through that endpoint
MailerLiteTurn on Double opt-in for API and integrations in subscribe settings. Send status: "unconfirmed" as well, which MailerLite needs before it emails an existing subscriberSending unconfirmed for an address that is already active asks them to confirm again
beehiivSend double_opt_override: "on" on create subscription, or "not_set" to use the publication's own setting"off" makes the subscriber active at once, with no email

Test each with an address you own before you rely on it. Tool settings change, and the email itself is set up in the tool: its subject, its wording, and the page the link opens.

A relay that confirms once

A plain relay asks for a confirmation every time a webhook arrives. Two cases make that wrong: a reader who submits three times in a minute gets three emails, and a bot that signs a victim's address up gets your confirmation in that victim's inbox each time it tries. This Cloudflare Worker asks the tool for at most one confirmation per address per day:

src/index.ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import { addUnconfirmed } from "./tool";

export interface Env {
  IKIFORM_WEBHOOK_SECRET: string;
  TOOL_API_KEY: string;
  SEEN: KVNamespace;
}

const DAY = 86_400;

async function digest(text: string) {
  const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
  return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}

export default {
  async fetch(request: Request, env: Env) {
    if (request.method !== "POST") return new Response(null, { status: 405 });
    const payload = await request.text();
    let event;
    try {
      event = await verifyWebhook({ payload, headers: request.headers, secret: env.IKIFORM_WEBHOOK_SECRET });
    } catch (error) {
      if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
      throw error;
    }
    if (event.type !== "submission.created" || event.data.fields.consent !== true) {
      return new Response(null, { status: 204 });
    }

    const email = String(event.data.fields.email).trim().toLowerCase();
    const key = `confirm:${await digest(email)}`;
    if (await env.SEEN.get(key)) return new Response("Confirmation already requested today", { status: 200 });

    const answer = await addUnconfirmed(env, email, event.data.fields.first_name);
    if (answer.status === 429) {
      return new Response("Rate limited", { status: 503, headers: { "retry-after": answer.headers.get("retry-after") ?? "60" } });
    }
    if (!answer.ok) return new Response(`Email tool answered ${answer.status}`, { status: 502 });

    await env.SEEN.put(key, event.id, { expirationTtl: DAY });
    return new Response(null, { status: 204 });
  },
};
wrangler.jsonc
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "newsletter-relay",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-01",
  "routes": [{ "pattern": "hooks.example.com", "custom_domain": true }],
  "kv_namespaces": [{ "binding": "SEEN", "id": "<your KV namespace id>" }]
}
  • addUnconfirmed in ./tool is the tool's call from the table above. Copy its body from the Kit, MailerLite or beehiiv page.
  • The KV key is a SHA-256 of the lowercased address, so the store never holds the address itself. It expires after a day.
  • The key is written only after the tool accepts the request. If the tool fails, Ikiform retries and the retry isn't blocked.
  • Answering 200 for a repeat tells Ikiform the delivery succeeded, so it isn't retried, and the body explains why in the delivery log.
  • The Worker runs on a custom domain. Ikiform refuses workers.dev hostnames as webhook URLs. The webhooks docs list every URL rule and what each answer means, and your own server shows a relay that stores each delivery once.
KV is eventually consistent across locations, so two deliveries for the same address within a few seconds can both get through. That's acceptable for a confirmation email. If you need a strict guarantee, keep the key in a Durable Object or a database with a unique column.

What the form should say

A reader who sees "Thanks for subscribing" and then finds nothing in their newsletter folder thinks it failed. Tell them what happens next, on the page they land on:

  1. Set the success line, or your thank-you page, to say there is an email to open: "Almost done. Open the email we sent you and confirm your address."
  2. Name the sender they should look for, and suggest the spam folder if nothing arrives in a few minutes.
  3. In the email tool, set the page the confirmation link opens to your own "You're subscribed" page, so the journey ends on your site.

The form's consent checkbox and the confirmation email do different jobs. The checkbox records that someone said yes on your page. The click proves the address belongs to the person who said yes. Most lists want both. The newsletter form template has the required checkbox.

Gotchas

  • Existing subscribers. Someone already confirmed signs up again from a new page. Don't move them back to unconfirmed; the MailerLite page shows a relay that looks the address up first.
  • People who unsubscribed. A signup isn't always permission to undo an unsubscribe. beehiiv only reactivates when you send reactivate_existing: true, and MailerLite only resubscribes with resubscribe: true. Send those only when the form says so.
  • Link scanners. Some company mail filters open links in incoming mail to check them. If a tool confirms on a plain page load, a scanner can confirm an address nobody clicked. Check whether your tool's confirmation page asks for a button press.
  • Deliverability. The confirmation is the first email a reader gets from you. Send it from an authenticated domain in your tool, or it lands in spam and the signup never completes.
  • Unconfirmed people pile up. They aren't subscribers yet. Decide in your tool whether to send a reminder, then leave them alone.

Check it works

  1. Submit the form with an address you own. The page tells you to check your inbox.
  2. Open the delivery under Webhooks in Ikiform. It shows succeeded with the relay's 204.
  3. Open the subscriber in your email tool. They're unconfirmed, inactive or pending, depending on the tool.
  4. Open the confirmation email and click. The subscriber becomes active or confirmed.
  5. Submit the same address again within a day. The delivery shows 200 with "Confirmation already requested today", and no second email arrives.
  6. Sign up an address that is already confirmed. It stays confirmed and gets no new confirmation email.

Questions

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.