Ship a Waitlist: Confirm Signups and Launch-Day Access Emails

Double opt-in confirmation and launch-day access emails are transactional — build a waitlist with Notify without turning it into a newsletter platform.

A waitlist needs two emails that must land:

  1. Confirm your signup (double opt-in)
  2. You’re in — here’s access on launch day

Those are transactional. They are triggered by user actions and product events — not by a marketing segment. This guide builds that loop with Next.js + Notify, and explicitly does not turn Notify into a newsletter tool.

Draw the line

| Do with Notify | Don’t | |----------------|-------| | Confirm email ownership | Weekly founder essays to the list | | Launch access / invite link | Promotional blasts to unconfirmed addresses | | “You’re off the waitlist” | Purchased/scraped lists |

If you later want a newsletter, use a newsletter product on a different subdomain. Keep transactional reputation clean.

Data model

create table waitlist (
  id uuid primary key default gen_random_uuid(),
  email text unique not null,
  confirm_token_hash text,
  confirm_expires_at timestamptz,
  confirmed_at timestamptz,
  access_email_sent_at timestamptz,
  created_at timestamptz default now()
);

Notify helper

// lib/notify.ts
export async function sendEmail(opts: {
  to: string;
  subject: string;
  message: string;
}) {
  const res = await fetch('https://notify.cx/api/email/send', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': process.env.NOTIFY_API_KEY!
    },
    body: JSON.stringify({
      from: 'hello@your-verified-domain.com',
      ...opts
    })
  });
  if (!res.ok) throw new Error(await res.text());
  return res.json();
}

1. Join waitlist → confirmation email

import { createHash, randomBytes } from 'crypto';

export async function joinWaitlist(email: string) {
  const normalized = email.trim().toLowerCase();
  const raw = randomBytes(32).toString('hex');
  const tokenHash = createHash('sha256').update(raw).digest('hex');

  await upsertWaitlistPending({
    email: normalized,
    confirmTokenHash: tokenHash,
    confirmExpiresAt: new Date(Date.now() + 1000 * 60 * 60 * 48)
  });

  const url = `${process.env.NEXT_PUBLIC_APP_URL}/waitlist/confirm?token=${raw}`;

  await sendEmail({
    to: normalized,
    subject: 'Confirm your waitlist spot',
    message: `
      <h1>One click to confirm</h1>
      <p><a href="${url}">Confirm my email</a></p>
      <p>This link expires in 48 hours. If you did not join the waitlist, ignore this email.</p>
    `
  });

  // Always return generic success to the UI
  return { ok: true };
}

2. Confirm route

export async function GET(request: Request) {
  const token = new URL(request.url).searchParams.get('token');
  if (!token) return new Response('Missing token', { status: 400 });

  const tokenHash = sha256(token);
  const row = await findValidConfirmToken(tokenHash);
  if (!row) return new Response('Invalid or expired', { status: 400 });

  await markConfirmed(row.id);
  return Response.redirect(`${process.env.NEXT_PUBLIC_APP_URL}/waitlist/thanks`);
}

Only confirmed rows get launch email.

3. Launch-day access email

When you flip the product live (admin action or cron):

export async function sendLaunchAccessEmails() {
  const batch = await listConfirmedWithoutAccessEmail(100);

  for (const row of batch) {
    const inviteUrl = await createInviteOrMagicLink(row.email);

    await sendEmail({
      to: row.email,
      subject: 'You’re in — access is ready',
      message: `
        <h1>Welcome aboard</h1>
        <p>The waitlist is open. Here’s your access link:</p>
        <p><a href="${inviteUrl}">Open the app</a></p>
      `
    });

    await markAccessEmailSent(row.id);
  }
}

Process in batches. Idempotent access_email_sent_at prevents double sends if the job retries.

Rate limits and abuse

  • Rate-limit joins by IP
  • Cap confirms per email
  • Reject disposable domains if that matters for your launch
  • Never email unconfirmed addresses at launch

Production checklist

  • [ ] Verified domain for from
  • [ ] Hashed confirm tokens + expiry
  • [ ] Generic join response (no email enumeration if you care)
  • [ ] Launch job idempotent
  • [ ] Logs checked for a sample of confirms (email logs)

Bottom line

Waitlist email is double opt-in + access delivery. That’s a transactional pipe — Notify’s job. Keep newsletters elsewhere.

Quick start · Magic links · Pricing