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.
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 reader ticks the consent box and submits your form, which posts to Ikiform.
- Ikiform stores the signup and sends a signed webhook to your relay.
- The relay verifies the signature and adds the reader to the email tool as unconfirmed.
- The email tool emails the reader a confirmation link.
- If they click, the tool marks them confirmed and sends them every issue.
- If they never click, they stay unconfirmed and receive no newsletter.
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 | 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 | 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 | 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:
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 });
},
};{
"$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>" }]
}addUnconfirmedin./toolis 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
200for 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.devhostnames 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.
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:
- 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."
- Name the sender they should look for, and suggest the spam folder if nothing arrives in a few minutes.
- 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 withresubscribe: 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
- Submit the form with an address you own. The page tells you to check your inbox.
- Open the delivery under Webhooks in Ikiform. It shows
succeededwith the relay's204. - Open the subscriber in your email tool. They're unconfirmed, inactive or pending, depending on the tool.
- Open the confirmation email and click. The subscriber becomes active or confirmed.
- Submit the same address again within a day. The delivery shows
200with "Confirmation already requested today", and no second email arrives. - Sign up an address that is already confirmed. It stays confirmed and gets no new confirmation email.
Questions
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.
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.
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.
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.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.