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:
- Confirm your signup (double opt-in)
- 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.