Skip to content

Double opt in email: how the confirmation flow works, and why the friction is worth it

Published Updated 10 min readBy the Rasket team

Two check marks in sequence beside a single envelope, drawn as white and violet outlines on black.

What a double opt in email is

A double opt in email is the message sent to a new subscriber immediately after they sign up, carrying a link they have to click before they are added to a list. The first opt-in is the form. The second is the click. Only the second one proves that the person who controls the inbox, rather than whoever typed the address, wants the mail.

The same flow goes by the names double opt-in and confirmed opt-in; some mailbox providers prefer the second because it says what the flow does. Whatever you call it, the shape is fixed: collect an address, send one confirmation, wait for the click, and only then record the subscription. Everything else in this post is the detail of doing those four things so that the record you end up with will stand up later.

It matters because a list is only as good as the worst address on it. A typo, a prank sign-up with a stranger’s address, a bot filling in forms — every one of those becomes a bounce or a complaint the first time you broadcast, and reputation is scored on exactly those two things. The confirmation step is a filter that runs before the damage rather than after it.

Single vs double opt-in

Single opt-in is the form alone: submit, and you are on the list. It is simpler and it grows a list faster, and both of those are true right up until the first campaign.

Single opt-in compared with double opt-in
Single opt-inDouble opt-in
What adds the addressThe form submissionThe click on the confirmation
Proof of consentA timestamp and whatever the form loggedA signed link only the inbox owner could have clicked
Typos and fake sign-upsLand on the list and bounce laterNever confirm, never get mailed
List growthFasterSlower by the share who never click
First-campaign bounce rateWhatever the form let throughClose to zero
Complaint riskHigher: some recipients never askedLower: every recipient clicked
EngineeringOne form handlerOne form handler, one send, one confirm route
Best forA closed audience you already know, such as existing customersAny public form, and anything you may one day have to prove consent for

The middle row is the one that decides it. Under single opt-in the list carries every mistake the form accepted, and you discover them as bounces during a broadcast, which is the worst possible moment. Under double opt-in the mistakes stay in a pending table you never send to. Hard bounces from a newsletter are almost always addresses that should never have been on it.

The confirmation flow, step by step

Five moves, and two of them are API calls. The first sends one transactional email; the second records the consent on the contact. Nothing about the flow is specific to a framework, so the samples are plain Node with fetch.

  1. Collect the address into a pending tableTake the address from your form and write it to a pending row on your side, with the time and the source. Do not create a contact yet: an unconfirmed address has no consent to record.
  2. Send one confirmation email with a signed linkSign an HMAC over the address and an expiry with a secret only your server holds, put the token in a link, and send it through POST /emails with an Idempotency-Key of confirm-<contactId> so a retried submission cannot send two.
  3. Verify the click and record the opt-in on the contactOn the confirm route, recompute the MAC, compare it in constant time and check the expiry. Then POST /contacts with topics: [{ id, subscription: "opt_in" }], which writes a consent record with its source and time.
  4. Store the proof on your side tooWrite your own row with the confirmation time, the IP address and the user agent against the contact id. The consent ledger says when; your row says from where, and you want both.
  5. Only then let the contact into a segmentA segment is a filter evaluated when a broadcast sends, so a contact that exists is one filter away from being mailed. Create the contact only after the click, and keep the pending table outside the audience.

Sign a token and send the confirmation

The link has to be something a stranger cannot forge and cannot reuse next year. An HMAC over the address and an expiry, keyed with a secret only your server holds, does both. The send goes through POST /emails like any other transactional message, with an idempotency key derived from the pending row so a retried form submission cannot produce two confirmations.

import { createHmac } from "node:crypto";
const SECRET = process.env.CONFIRM_SECRET!;const TTL_MS = 48 * 60 * 60 * 1000; // 48 hours
export function confirmationToken(email: string, expiresAt: number): string {  const payload = Buffer.from(JSON.stringify({ email, expiresAt })).toString("base64url");  const mac = createHmac("sha256", SECRET).update(payload).digest("base64url");  return `${payload}.${mac}`;}
/** contactId is the id of the pending row you wrote when the form was submitted. */export async function sendConfirmation(contactId: string, email: string): Promise<string> {  const token = confirmationToken(email, Date.now() + TTL_MS);  const url = `https://acme.example/confirm?token=${encodeURIComponent(token)}`;
  const response = await fetch("https://api.rasket.com/emails", {    method: "POST",    headers: {      authorization: `Bearer ${process.env.RASKET_API_KEY}`,      "content-type": "application/json",      "user-agent": "acme-billing/1.0",      "idempotency-key": `confirm-${contactId}`,    },    body: JSON.stringify({      from: "Acme <news@send.acme.example>",      to: [email],      subject: "Confirm your subscription to Acme news",      html: `<p>You asked to hear from us. <a href="${url}">Confirm your subscription</a>.</p><p>If this was not you, ignore this message and nothing will be sent.</p>`,      text: `You asked to hear from us. Confirm here: ${url}\n\nIf this was not you, ignore this message.`,    }),  });
  if (!response.ok) throw new Error(`confirmation send failed: ${response.status}`);  const { id } = (await response.json()) as { id: string };  return id;}

Note what the message does not contain: no offer, no news, nothing that could make it look like the first issue. A confirmation that is itself marketing is exactly what the recipient has not yet agreed to. It also names the sending address for the newsletter itself, so the person sees the same sender once they are subscribed.

Verify the click and record the consent

The confirm route reverses the first step: split the token, recompute the MAC, compare in constant time, check the expiry, and only then treat the address as confirmed. Then it makes the second API call — POST /contacts with the topic subscription set to opt_in — and writes its own proof row.

import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.CONFIRM_SECRET!;const TOPIC_ID = process.env.NEWSLETTER_TOPIC_ID!;
function verifyToken(token: string): string | null {  const [payload, mac] = token.split(".");  if (!payload || !mac) return null;
  const expected = createHmac("sha256", SECRET).update(payload).digest("base64url");  const given = Buffer.from(mac);  const wanted = Buffer.from(expected);  if (given.length !== wanted.length || !timingSafeEqual(given, wanted)) return null;
  const { email, expiresAt } = JSON.parse(Buffer.from(payload, "base64url").toString());  return expiresAt > Date.now() ? email : null;}
export async function confirm(token: string, proof: { ip: string; userAgent: string }) {  const email = verifyToken(token);  if (email === null) return "expired_or_invalid";
  const response = await fetch("https://api.rasket.com/contacts", {    method: "POST",    headers: {      authorization: `Bearer ${process.env.RASKET_API_KEY}`,      "content-type": "application/json",      "user-agent": "acme-billing/1.0",    },    body: JSON.stringify({      email,      topics: [{ id: TOPIC_ID, subscription: "opt_in" }],    }),  });  if (!response.ok) throw new Error(`contact write failed: ${response.status}`);  const { id } = (await response.json()) as { id: string };
  // Your own copy of the proof: when, from where, with what.  await db.consent.insert({    email,    contactId: id,    confirmedAt: new Date(),    ip: proof.ip,    userAgent: proof.userAgent,  });  return "confirmed";}

The topics entry is the load-bearing part of the contact write. Every subscription change on a contact writes a consent record with its source and time, and the contacts reference documents the same change as PATCH /contacts/{id}/topics for an address you already hold. Creating the contact with the subscription in the same request means there is no moment at which the address exists in your audience without its consent attached.

Keep your own copy of the proof

The consent ledger on the contact says a subscription was recorded and when. Your row says from which IP, with which user agent, against which token. If a complaint or a regulator ever asks how this person came to be on the list, you want both answers, and you want them from a table you control.

What the law and mailbox providers expect

This is not legal advice, and consent rules differ by country. What follows is the shape of the two requirements a developer building the flow is most likely to be asked about.

Being able to demonstrate consent

Under the GDPR, where processing rests on consent, Article 7 requires the controller to be able to demonstrate that the person consented, and gives them the right to withdraw it as easily as they gave it. The text is on EUR-Lex. A double opt-in flow is not named there; what the article asks for is evidence, and a signed link that only the inbox owner could have clicked, plus a record of when they did, is the plainest evidence a newsletter can hold. The withdrawal half is the unsubscribe, which is why it must work in one action.

What the large mailbox providers require of bulk senders

Google’s sender guidelines set out what a sender of bulk mail to its users has to do: authenticate with SPF, DKIM and DMARC, support one-click unsubscribe and honour it within two days, and keep the spam rate reported in Postmaster Tools below 0.3%. Yahoo publishes matching requirements. The one-click mechanism is RFC 8058: a List-Unsubscribe header carrying an HTTPS URL and a List-Unsubscribe-Post header, so the mail app can unsubscribe the reader with a single POST. Every broadcast Rasket sends carries both, and the reader’s choice is written back to the same consent ledger the opt-in was.

A 0.3% complaint rate is three people in a thousand. A single-opt-in list that has picked up a few hundred addresses nobody confirmed can cross that in one send. The point of the confirmation is that nobody on the list is surprised to hear from you, and an unsurprised reader does not press the spam button.

Handling the people who never confirm

Some share of sign-ups will not click. Some were typos, some were bots, some were real people who lost interest between the form and the inbox. The rule for all of them is the same: they are not subscribers, and the pending table must never be mailed.

  • Expire the token. Forty-eight hours is a common window. After it, the link fails with a plain message and an offer to sign up again, which sends a fresh confirmation with a fresh token.
  • One reminder, at most. A second confirmation a day or two later, keyed confirm-<contactId>-2 so it cannot double either, is defensible. A third message is a newsletter to someone who has now twice declined to confirm.
  • Never add them. Not to a segment, not to a “maybe” topic, not with opt_out as a placeholder. An address with no confirmation has no consent to record, so there is nothing to write. Delete the pending row after the window closes.

The reason the pending list must stay out of reach is structural rather than moral. A segment is a filter evaluated when a broadcast sends, so a contact that exists at all is one filter change away from being mailed. The safest place for an unconfirmed address is outside the audience entirely, in a table the broadcast pipeline cannot see. Once the click arrives, the contact is created with its opt_in and becomes eligible for the segments broadcasts target — and sending the newsletter itself is a separate post.

Frequently asked questions

What is the difference between single and double opt-in?

Single opt-in adds an address to the list the moment a form is submitted. Double opt-in sends a confirmation email first and adds the address only when the link in it is clicked. The second proves the inbox owner asked for the mail; the first proves only that somebody typed the address.

Is double opt-in required by law?

In general terms, no law names the flow, and this is not legal advice. What regimes such as the GDPR ask for is that you can demonstrate consent and that withdrawing it is as easy as giving it. A signed confirmation link with a recorded click is the plainest evidence a newsletter can hold, which is why the flow is so widely used.

How long should the confirmation link stay valid?

Long enough for someone to find the message and short enough that a leaked link is worthless later; 24 to 48 hours is a common window. After it expires, the confirm route should fail with a plain message and an offer to sign up again, which issues a fresh token rather than reviving the old one.

What should I do with people who never confirm?

Nothing, apart from one reminder at most. They are not subscribers, so they are never added to a contact, a topic or a segment, and the pending row is deleted once the window closes. A pending list that can be mailed is a single-opt-in list with extra steps.

Should the confirmation email contain any marketing?

No. The recipient has not yet agreed to marketing, so the confirmation should say what they signed up for, carry the link, and tell them to ignore it if it was not them. It is a transactional message caused by their action, and it should read like one.

Where does Rasket record the consent?

On the contact, per topic. Creating a contact with topics: [{ id, subscription: "opt_in" }] or patching its topics writes a consent record with its source and time, and an unsubscribe from the one-click headers or the preference page is written to the same ledger. Keep your own copy of the proof as well, with the IP and user agent.

Sources

  1. Email sender guidelinesGoogle, read 2026-09-16
  2. RFC 8058: Signaling One-Click Functionality for List Email HeadersRFC Editor, read 2026-09-16
  3. Regulation (EU) 2016/679 (GDPR), Article 7: Conditions for consentEUR-Lex, read 2026-09-16
  • Double opt-inDouble opt-in is asking somebody to confirm a subscription by clicking a link in an email before you add them to a list.
  • ContactsYour audience: contacts, their typed properties, segments and topic choices.
  • TopicsWhat contacts subscribe to, and the preference page's list.
  • BroadcastsAn audience you own: contacts with typed properties, segments, topics people subscribe to, and broadcasts sent through the same pipeline as your other mail.
  • Newsletter API: send broadcasts from your codeSend a newsletter over an API in six calls: create a topic, add contacts with consent, build a segment, create the broadcast, pass the gate and read the report.

Start sending this morning

Sign up, verify a domain and send your first email in minutes.