# 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.

Published 2026-10-25 · https://ikiform.com/guides/newsletter-signup/double-opt-in

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](https://ikiform.com/docs/webhooks.md). 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`.

Diagram: The life of a double opt-in signup, from form to confirmed subscriber

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:

| Tool | How an API signup gets a confirmation email | The trap |
| --- | --- | --- |
| [Kit](https://ikiform.com/guides/newsletter-signup/kit.md) | Create 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 own | Create subscriber defaults to `state: "active"`, and the state can't be changed afterwards through that endpoint |
| [MailerLite](https://ikiform.com/guides/newsletter-signup/mailerlite.md) | Turn on **Double opt-in for API and integrations** in subscribe settings. Send `status: "unconfirmed"` as well, which MailerLite needs before it emails an existing subscriber | Sending `unconfirmed` for an address that is already active asks them to confirm again |
| [beehiiv](https://ikiform.com/guides/newsletter-signup/beehiiv.md) | Send `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`:

```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`:

```json
{
  "$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](https://ikiform.com/docs/webhooks.md) list every URL rule and what each answer means, and [your own server](https://ikiform.com/blog/form-submissions-to-your-own-server.md) 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](https://ikiform.com/forms/newsletter-signup) 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](https://ikiform.com/guides/newsletter-signup/mailerlite.md) 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

### Can Ikiform send the double opt-in confirmation email?

No. Ikiform never emails the person who submits a form. Your email tool sends the confirmation, triggered by Ikiform's webhook through a relay or an automation tool.

### Do I still need a consent checkbox if I use double opt-in?

They do different jobs. The checkbox records that the person agreed on your page, with a timestamp. The confirmation click proves they own the address. Most lists keep both.

### Why did my subscriber get no confirmation email?

The relay most likely created them as already confirmed: Kit's create subscriber defaults to active, MailerLite needs its API double opt-in setting on, plus an unconfirmed status for existing subscribers, and beehiiv's double_opt_override set to off skips the email. Check the subscriber's state in the tool.

### Can I use Zapier or Make instead of a relay?

Yes. Point the Ikiform webhook at the automation and use its action for your email tool. Check that the action's options create the subscriber in an unconfirmed state; you lose the once-a-day guard unless you add a filter step.

## More guides

- [Newsletter signup form](https://ikiform.com/guides/newsletter-signup.md): Build a newsletter signup form in your own markup, record consent, block bot signups and pass each subscriber to Kit, MailerLite, beehiiv or any tool.
- [Kit (ConvertKit)](https://ikiform.com/guides/newsletter-signup/kit.md): Connect your own newsletter form to Kit with a relay that creates the subscriber, adds them to a Kit form for the incentive email, and tags the placement.
- [MailerLite](https://ikiform.com/guides/newsletter-signup/mailerlite.md): Connect your own signup form to MailerLite: a relay adds new readers as unconfirmed for double opt-in and never asks a subscriber to confirm twice.
- [beehiiv](https://ikiform.com/guides/newsletter-signup/beehiiv.md): Connect your own signup form to beehiiv: a relay creates each subscription with double opt-in on, the placement as UTM source and the page as referrer.
- [Static site](https://ikiform.com/guides/newsletter-signup/static-site.md): Add a newsletter signup form to a Hugo, Jekyll, Eleventy or Astro static site, keep the email tool's API key out of the page and confirm in the tool.
- [Next.js newsletter form](https://ikiform.com/guides/newsletter-signup/nextjs.md): A Next.js footer newsletter form: a server action with a consent check and Turnstile, posting to Ikiform, plus a route handler that adds readers to Kit.
