Double opt in email: how the confirmation flow works, and why the friction is worth it
Published Updated 14 min readBy the Rasket team
Written for deliverability specialists and email marketers

A double opt in email is the confirmation message a new subscriber must click before you add them to your list. You send it because only a working mailbox can confirm, so typos, spam traps and strangers’ addresses never join, and every subscriber leaves a dated record of saying yes.
In subscription bombing, a script submits its victims’ addresses to hundreds of sign-up forms at once, often to bury a real alert under the flood, and your form can be one of them. Overnight, 3,000 strangers are on your list. With single opt-in, your next campaign goes to people who never asked, and their spam reports land on your domain. With double opt-in, each of them gets one plain confirmation email, ignores it, and is never added.
Using a form builder? Everything above the code is yours. Building the flow yourself? Skip to the code.
How it works, and what the click proves
- Someone submits your form. You hold the address as pending; it is not on your list.
- You send one plain email with a single-use link that expires.
- The link opens a page with a Confirm button. Pressing it adds them to the list.
- You keep the record: when they signed up, when they confirmed, and from where.
Be precise about the proof. A confirmed click shows that someone with access to that mailbox, or software acting for it, opened the link. It does not prove a person read anything. Company security filters and some mail apps open every link in an incoming message to check it for malware, so a link that subscribes on a plain visit gets “confirmed” by a scanner seconds after delivery. That is why the link opens a page, and only pressing the page’s button records the opt-in.
A few security tools press buttons too. Catching them takes code on the confirmation page; the build section shows how.
Single vs double opt-in
| Who submits the form | Single opt-in | Double opt-in |
|---|---|---|
| A person who mistypes (jane@gmial.com) | Joins; your first campaign bounces, or lands at a typo domain that accepts anything | The confirmation goes nowhere useful, and the address never joins |
| A bot, with a stranger's address | Joins, and the stranger may report you as spam | The stranger gets one email, ignores it, and never joins |
| A bot, with a spam trap address | Joins, and every campaign hits the trap | Nobody can confirm it, so it never joins |
| A real subscriber | Joins at once; your evidence is the form submission | Joins after the click; your evidence is the submission plus the confirmation |
The mailbox providers ask for it. Google’s sender guidelines say to “confirm each recipient’s email address before subscribing them”, and Yahoo’s tell senders to email new subscribers “asking them to click to confirm their opt-in”. Neither says how much it moves inbox placement. What it changes is measurable on your side: fewer hard bounces, no spam traps on your list (addresses that exist only to catch senders who mail people who never asked), fewer spam reports, and fewer messages deleted unread. Google tells bulk senders to keep the reported spam rate below 0.1% and never reach 0.3%; the complaint rate guide explains how it is measured.
Two limits remain. The confirmation email itself still reaches any trap a bot types in, and enough trap hits in a bombing run can put your domain on a blocklist, which is why the form needs the protections below. And a confirmed address can be abandoned and later turned into a recycled trap; only bounce handling and sunsetting inactive subscribers catch that.
What it costs, and how to make the case
Double opt-in loses the real people who never click, and nobody publishes a loss rate that holds across lists. Raw sign-ups flatter single opt-in, because they count the typos, bots and traps too. To make the case, compare the two per 1,000 form submissions on what you earn from. An illustration with invented numbers, to show the method: single opt-in adds 1,000 contacts, 30 hard-bounce and 4 report spam, and 180 click something within 90 days; double opt-in adds 650, none bounce, 1 reports spam, and 170 click. Fill the same table with your own numbers before deciding. Single opt-in only spares you the check where the address is already proven, such as customers you send receipts to; whether you may email them marketing is a separate question, covered under the law below. Any public form should confirm.
Without code: Rasket's sign-up forms
If you use Rasket’s sign-up forms, double opt-in is a setting on the form, and the design in this article is built in: the link opens a page whose button subscribes, and the link expires after 48 hours. The confirmation goes out from an address on your verified domain, with your subject, words and button. It still reaches someone who once unsubscribed, because it is how they come back; an address suppressed after a bounce or a spam complaint gets nothing.
The forms carry a honeypot and limits per network and per address, and a project’s forms send at most 5,000 confirmation emails a day; the Forms page says when that ceiling is reached. They do not flag fast presses. A refused sign-up gets the same answer as a real one, so the form reveals nothing about who is subscribed. The forms guide covers setting one up. If your list already lives in a newsletter tool whose forms confirm sign-ups, turn the setting on there; you do not need Rasket for this.
What the confirmation email should say
One job: get the click. A subject such as “Confirm your subscription to Acme News”, the sender name and domain your newsletter will use, one sentence saying what they signed up for, one large button, and a line saying to ignore the email if it was not them. No offers, no news and no images that could hide the button. Send it within seconds of the sign-up, while the person is still looking for it.
Catch typos before anything is sent: suggest “did you mean gmail.com?” when someone types a near miss of a big mail domain.
Measure your confirmation rate
Divide confirmations by submissions over seven days, and read the numbers two days after the week ends, so the last sign-ups’ links have had time to be used. Rasket’s Forms page shows both counts by day; your own history is the baseline. Our rule of thumb: look into it when fewer than half confirm, or when the rate drops 10 points below your four-week average. If submissions jumped at the same time, suspect bots. If not, split the rate by the domain people signed up with (gmail.com, outlook.com, yahoo.com). A drop at one provider means the confirmation is being filtered there. Read the bounce and deferral codes your sending logs show for that provider, but mail filed as spam is accepted and leaves no code, so clean logs do not clear it; check the provider’s own data too, such as the spam rate in Google Postmaster Tools for Gmail. A drop everywhere points at the email or the page after the form.
What to send after they confirm
Send the welcome email within minutes of the click, while they still remember signing up. Lead with what the form promised (the discount code, the download, the first issue), then say how often you will write. On Rasket, a confirmation subscribes the contact to the form’s topic, which starts any automation whose trigger is Topic subscribed, so the welcome is a one-step automation. A form with a discount code also shows the code on the confirmation page once the click lands. How to write a welcome email covers the email itself.
What the law expects
This is not legal advice. In the EU the rule that marketing email needs consent comes from the ePrivacy Directive (Article 13), and in the UK from PECR; the GDPR defines that consent, and its Article 7(1) says the sender must be able to demonstrate it. No statute names double opt-in. In Germany, the Federal Court of Justice (BGH, I ZR 164/09, 2011), in a case about telephone marketing, held that a sender must document each consent, and said email double opt-in normally shows a sign-up came from that address, though the person can still show it did not. German practitioners treat confirmed opt-in as the practical standard there. In Canada, CASL does not name it either, but puts the burden of proving consent on the sender. In the US, CAN-SPAM does not require it. Everywhere, keep each confirmation’s record.
Handling the people who never confirm
- Let the link expire. Pick a short window and say it in the email. An expired link should say so plainly and offer to send a new one.
- Send one reminder at most, a day later, with a fresh link, and none while sign-ups are spiking or for a sign-up you flagged: there, a reminder doubles the mail to victims and traps.
- Never add them anywhere. Not to a list, a segment, or a topic marked as opted out. Delete the pending sign-up when its link expires.
- Do not re-confirm your existing list by default. A re-permission campaign also loses real subscribers who simply ignore it. Addresses you cannot account for, such as an old import, are not a reason to email either. In the UK, where the ICO fined Flybe and Honda in 2017 for emails asking for marketing consent, and in Canada, where CASL section 1(3) counts a request for consent as a commercial message, asking is itself marketing, so it needs the consent you are missing. Leave them out and ask on your site or at their next sign-in instead.
Once a contact is confirmed, it can be targeted by campaigns like any other; the newsletter API article covers sending them.
Protecting the form from subscription bombing
Each fake sign-up still costs one confirmation email. Never copy what the visitor typed, such as a name or company, into it: attackers fill those fields with links to send their message through your domain. Then put four controls in front of the form; each catches what the one before misses.
- A honeypot. A field hidden from people with CSS. Simple scripts fill every field and are dropped silently; bots that drive a real browser skip it, which is why the next three exist.
- Rate limits per IP address and per email address. Allow an office several sign-ups from one IP, and one email per address per window, so a double click sends once.
- A challenge when you are under attack. A CAPTCHA or an invisible bot check, turned on when the volume of sign-ups jumps, rather than for every visitor.
- A daily ceiling on confirmation emails. Set it at a few times your busiest real day. When it is reached, stop sending and alert someone: a form that can send without limit lets somebody else spend your reputation.
Building it yourself
For developers: one TypeScript file with three handlers, for your form, the page the link opens, and the button’s POST. Save it as confirm.ts and call the handlers from your routes; Node 22.18 or later loads TypeScript directly, or use tsx. It calls the Rasket API with plain fetch and keeps its state in memory, so it works on one process; swap the maps for tables.
import { createHash, randomBytes } from "node:crypto";
const API = "https://api.rasket.com";
const TOPIC_ID = process.env.NEWSLETTER_TOPIC_ID!;
const TTL_MS = 48 * 60 * 60 * 1000; // the link works for 48 hours
const TOKEN = /^[A-Za-z0-9_-]{43}$/; // 32 random bytes, base64url
const DAILY_CEILING = 500; // your own number: a few times your busiest real day
type Pending = { email: string; expiresAt: number; signedUpAt: Date; ip: string };
const pending = new Map<string, Pending>(); // keyed by the token's hash; use a table in production
const recent = new Map<string, number[]>(); // sign-up times by IP and by address
const sentToday = { day: "", count: 0 }; // confirmation emails sent today, by UTC date
const TEN_MINUTES = 10 * 60 * 1000;
/** At most `max` sign-ups per key in ten minutes. */
function allowed(key: string, max: number, now: number): boolean {
const times = (recent.get(key) ?? []).filter((time) => now - time < TEN_MINUTES);
if (times.length >= max) return false;
recent.set(key, [...times, now]);
return true;
}
/** Count one more confirmation email today, or refuse once the ceiling is reached. */
function underCeiling(now: number): boolean {
const day = new Date(now).toISOString().slice(0, 10);
if (sentToday.day !== day) Object.assign(sentToday, { day, count: 0 });
if (sentToday.count >= DAILY_CEILING) return false;
sentToday.count += 1;
return true;
}
// Expired links stop working on their own; this keeps the map from growing.
setInterval(() => {
for (const [hash, row] of pending) if (row.expiresAt < Date.now()) pending.delete(hash);
}, 60 * 60 * 1000).unref();
const sha256 = (value: string) => createHash("sha256").update(value).digest("hex");
async function rasket(method: string, path: string, body: unknown, idempotencyKey?: string) {
const response = await fetch(`${API}${path}`, {
method,
headers: {
authorization: `Bearer ${process.env.RASKET_API_KEY}`,
"user-agent": "acme-news/1.0",
"content-type": "application/json",
...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}),
},
body: JSON.stringify(body),
});
if (!response.ok) throw new Error(`${path}: ${response.status} ${await response.text()}`);
return response.json();
}
/**
* POST /subscribe, from your form. Every refusal looks like success to the visitor. `ip` is the
* client address your host reports, never a raw X-Forwarded-For an attacker can rotate.
*/
export async function subscribe(form: { email: string; website?: string }, ip: string) {
if (form.website) return; // honeypot: a hidden field people never fill in
const now = Date.now();
const email = form.email.trim(); // sent to as typed: the part before @ can be case-sensitive
const key = `email:${email.toLowerCase()}`;
// Ten per IP (offices share one), one per address: a double click sends one email.
if (!allowed(`ip:${ip}`, 10, now) || !allowed(key, 1, now)) return;
if (!underCeiling(now)) {
console.error("confirmation ceiling reached: sign-ups are being dropped"); // page someone
return;
}
const token = randomBytes(32).toString("base64url");
const hash = sha256(token);
pending.set(hash, { email, expiresAt: now + TTL_MS, signedUpAt: new Date(now), ip });
const url = `https://acme.example/confirm?token=${token}`;
try {
await rasket(
"POST",
"/emails",
{
from: "Acme News <news@news.acme.example>",
to: [email],
subject: "Confirm your subscription to Acme News",
text: `Someone, hopefully you, asked for Acme News at this address.\n\nConfirm: ${url}\n\nIf it was not you, ignore this email. Nothing else will be sent.`,
},
`confirm-${hash.slice(0, 40)}`,
);
} catch (error) {
pending.delete(hash);
recent.delete(key); // nothing was sent, so the visitor may try again now
throw error;
}
}
/** GET /confirm: show a button and write nothing. Mail scanners open links too. */
export function confirmPage(token: string): string {
if (!TOKEN.test(token)) return "<p>This link is not valid.</p>";
return `<form method="post" action="/confirm">
<input type="hidden" name="token" value="${token}">
<input type="hidden" name="shown" value="${Date.now()}">
<button type="submit">Yes, subscribe me</button>
</form>`;
}
/** POST /confirm: the only place a subscription is recorded. */
export async function confirm(
form: { token: string; shown?: string },
proof: { ip: string; userAgent: string },
) {
if (!TOKEN.test(form.token)) return "expired_or_invalid"; // also refuses a missing token
const hash = sha256(form.token);
const row = pending.get(hash);
if (!row || row.expiresAt < Date.now()) return "expired_or_invalid";
// Pressed within a second of the page loading, or with no page load at all: likely software.
const suspect = !(Date.now() - Number(form.shown) >= 1000);
// Creates the contact, or returns the existing one for this address.
const contact = await rasket("POST", "/contacts", { email: row.email });
await rasket("PATCH", `/contacts/${contact.id}/topics`, {
topics: [{ id: TOPIC_ID, subscription: "opt_in" }],
});
pending.delete(hash); // only now: if a call above failed, the link still works for a retry
// Your own record of the proof, next to the consent record on the contact.
console.log("consent", { ...row, contactId: contact.id, confirmedAt: new Date(), confirmedFrom: proof, suspect });
return "confirmed";
}Spotting software that presses the button
Most scanners only open pages, but some sandboxes load the page and press buttons too. They press within a second of the page loading; a person reading it does not. Our rule of thumb, not an industry figure: flag a confirmation made less than a second after the page was shown, or made without the page ever being shown, which is what suspect records. Two signals that look useful are not: the time since the email was sent (real people often wait in their inbox and click at once) and a different IP address at sign-up and confirmation (a laptop, then a phone). Don’t delete a flagged contact. If its only clicks in your first three campaigns are scanner-shaped, every link within seconds of delivery, remove it.
Why it is built this way
- The token is random and stored hashed. A leaked database cannot be turned back into working links, and deleting the row makes the link stop working.
- The page checks the token’s shape before echoing it. It came from a URL, so anything else is refused rather than written into HTML.
- The page stamps the time it was shown. The POST compares it with its own time to set
suspect. A script written against your form could forge the field, so the flag catches generic scanners, not a targeted attack. - The topic is set with an explicit PATCH. Creating a contact never resubscribes someone who opted out earlier; the explicit topic change does, and it writes a consent record with its time and source. It also starts any
Topic subscribedautomation, as a form’s confirmation does. - The idempotency key comes from the token. The sample does not retry; if you wrap the send in a retry after a timeout, the key makes it go out once. A resubmitted form gets a new token, so the rate limit is what stops that.
Frequently asked questions
Is the confirmation email transactional or marketing?
Treat it as transactional in what it says: the person asked for it moments ago and it only completes their request, so keep offers and news out of it. Google's sender FAQ lists form submission confirmations among the transactional messages its one-click unsubscribe rule excludes. The law is less settled: one German appeals court (OLG München, 2012) treated a confirmation email as advertising, so a neutral message is also the safer one.
Should the confirmation come from the same domain as my newsletter?
Yes. Send it from the domain and sender name your newsletter will use, so the name people confirm is the name they see next. If your campaigns go from a subdomain such as news.example.com, send the confirmation from it too. Its spam reports and bounces count toward that domain's reputation like any other mail, which is one more reason to keep it plain.
What should the page after the form say?
That an email is on its way and must be confirmed, the address it went to so a typo is obvious, a hint to check the spam and promotions folders, and a way to ask for a new email if none arrives within ten minutes. With the email itself, it is the part of the flow you control.
What happens if someone signs up twice?
A repeat inside the rate-limit window is dropped, so a double click sends one email. After that, a new sign-up issues a new token and a new email, and both links work until they expire. If the address is already confirmed, say thanks without revealing to whoever typed it whether that address is on your list.
Will the confirmation email land in Gmail's Promotions tab?
It can. Gmail sorts mail into tabs by itself, from who sent it, what the content looks like and how people treated similar mail, and Google says the reader's own choices weigh most; no header or setting picks the tab. A plain message with one button and no offers gives it the least reason to look promotional. The Gmail Promotions tab guide covers how the sorting works.
Can I import contacts I confirmed somewhere else?
Yes. Create them with POST /contacts and their topic set to opt_in, or import a CSV, and keep the original proof of each confirmation, because the consent record on the contact will show the import, not the original click. Addresses you cannot show a confirmation for are better left out.
Sources
- Regulation (EU) 2016/679 (GDPR), Article 7: Conditions for consent — EUR-Lex, read 2026-10-17
- Directive 2002/58/EC (ePrivacy Directive), Article 13: Unsolicited communications — EUR-Lex, read 2026-10-17
- Bundesgerichtshof, judgment of 10 February 2011, I ZR 164/09 (Double-opt-in-Verfahren) — Bundesgerichtshof, via WIPO Lex, read 2026-10-17
- Canada's Anti-Spam Legislation, S.C. 2010, c. 23, sections 1(3) and 13 — Justice Laws Website, Government of Canada, read 2026-10-17
- Email sender guidelines — Google, read 2026-10-17
- Sender Requirements & Recommendations — Yahoo Sender Hub, read 2026-10-17
- Email sender guidelines FAQ — Google, read 2026-10-17
- CAN-SPAM Act: A Compliance Guide for Business — US Federal Trade Commission, read 2026-10-16
- Making Gmail's tabbed inbox work better for you — Google Workspace Blog (16 January 2020), read 2026-10-17
- Rasket docs: Sign-up forms (guide) — Rasket, read 2026-10-17
- Rasket docs: Contacts API reference — Rasket, read 2026-10-17
Related
- Sign-up forms — Pop-ups and embedded forms on your site, and the rules that pick one.
- Double opt-in — Double opt-in is asking somebody to confirm a subscription by clicking a link in an email before you add them to a list.
- Contacts — Your audience: contacts, their typed properties, segments and topic choices.
- How to lower your email spam complaint rate — What a good email spam complaint rate is, how Gmail and Yahoo measure it, how to read it on a small list, and the steps that bring it down.
- Email list cleaning: sunset inactive subscribers — Define inactive subscribers when Apple inflates opens, spot scanner clicks, run a re-engagement flow, and sunset the rest without blocking their receipts.
- How to write a welcome email that gets read — Welcome email best practices: what to write, how to protect the signup form from bots, example emails and sequences, and how to measure without opens.
- Newsletter API: send campaigns from your code — Send a newsletter from code or an AI agent: contact, segment, draft, test, human approval, then send. Runnable Node scripts and the MCP flow, with real output.