Guides

Guides17 min read

How to create a waitlist for a new SaaS

Build a pre-launch waitlist on your own landing page: the form, spam protection, duplicates, confirmation emails and a clean export on launch day.

Preet SutharFounder, Ikiform

Put a plain HTML form on your landing page that posts the visitor's email to a form backend, so you don't run a server before launch. Forward each signup by webhook to the email tool you'll launch with, and let that tool send any confirmation. On launch day, export the list as CSV, remove duplicates by email, and send one launch email.

A waitlist is the cheapest test of a SaaS idea. If strangers leave their email for something that doesn't exist yet, you have a reason to build it, and a list of people to tell when it ships. This guide builds one on your own landing page, without a database or a server, and gets it ready for launch day.

How a waitlist works

Every waitlist has the same three parts: a form on your page, somewhere that stores each signup, and the tool you'll email the list from. Here, the form posts to Ikiform. Ikiform checks the post for spam, stores it, emails you, and sends a signed webhook. A small relay or an automation tool hands the address to your email tool.

How a waitlist signup travels from your landing page to your email toollanding pageyour form, anysite or builderIkiformchecks spam,stores thesignupdashboardsearch, filter,export CSVnotificationemailto you, not thesignupsignedwebhooksubmission.createdemail toolconfirms andsends at launchpostrelayHow a waitlist signup travels from your landing page to your email toollandingpageyour form, anysite or builderIkiformchecks spam,stores thesignupdashboardsearch, filter,export CSVnotificationemailto you, not thesignupsigned webhooksubmission.createdemail toolconfirms and sendsat launchpostrelay
  1. A visitor enters their email on your landing page, and the form posts to your Ikiform endpoint.
  2. Ikiform checks the honeypot, the spam score and the rate limit, then stores the signup.
  3. The signup shows up in the dashboard, where you can search it and export the list as CSV.
  4. Ikiform emails you and your team. It never emails the person who signed up.
  5. Ikiform sends a signed submission.created webhook.
  6. A relay or an automation tool adds the address to your email tool, which sends any confirmation and, later, the launch email.
The email tool is optional on day one. You can collect for weeks and import a CSV later; the webhook keeps both in step from the start.

Four ways to run a waitlist

There's no single right tool. The table compares the four common setups by what they're good at and what they cost you later.

ApproachGood atTrade-offs
Waitlist SaaS, such as LaunchList or WaitlisterReferral links, leaderboards and position in line, out of the box. A hosted page if you have no site yet.Your list lives in another product. The widget looks like theirs unless you pay or restyle it. Pricing usually grows with signups.
Form builder embed, such as Typeform or TallyPolished one-question flows, with no code at all.An iframe that doesn't match your page and loads its own scripts. Branding, response limits and webhooks depend on the plan.
Your own database and API routeFull control over the data and any logic, such as referral codes.You build spam protection, rate limits, notifications and export, and you need a server even if the landing page is static.
Form backend, such as IkiformYour own HTML on any site. Spam filtering, storage, email alerts, signed webhooks and CSV export with no server.No referral leaderboard and no emails to the person who signs up. Those come from your email tool.

Pick a waitlist SaaS if referral mechanics are the whole plan. Pick a form backend if you want the form to look like the rest of your site, keep the list portable, and send email from the tool you'll use after launch anyway. The rest of this guide uses the form backend.

The form

Ask for an email and nothing else to start. Every extra field costs signups, and you can ask questions later, by email, of people who already said yes. Pick your stack below; the choice carries over to every code sample in these guides.

Paste the form where the signup should appear. It posts the plain way and redirects, so it works with no JavaScript at all.

index.html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" placeholder="you@company.com" autocomplete="email" required />
  <input type="hidden" name="ref" value="homepage" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Join the waitlist</button>
</form>

To keep visitors on the page, add this script after the form. It posts with fetch, swaps the form for the thank-you line and shows the server's field errors.

waitlist.js
const form = document.querySelector('form[action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx"]');
const status = document.createElement("p");
status.setAttribute("role", "alert");
form.append(status);

const labels = {
  email: "Email",
};

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const button = form.querySelector("button");
  button.disabled = true;
  status.textContent = "";
  try {
    const response = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(form),
    });
    const result = await response.json();
    if (result.ok) {
      form.replaceWith("You're on the list. We'll email you at launch.");
      return;
    }
    const fields = Object.entries(result.error.fields ?? {});
    status.textContent = fields.length
      ? fields.map(([id, messages]) => `${labels[id] ?? id}: ${messages[0]}`).join(" ")
      : result.error.message;
  } catch {
    status.textContent = "Couldn't reach the server. Try again.";
  }
  button.disabled = false;
});

Going further with HTML? Read How to add a waitlist form in plain HTML to any landing page.

  • The endpoint is your form's URL. Replace frm_xxxxxxxxxxxxxxxx with the id from the dashboard.
  • name="email" is the field id. Keep it as email: Ikiform uses it as the reply-to address of notification emails, and checks it against disposable email domains.
  • The hidden _gotcha input is the honeypot. People never see it. Bots that fill in every input mark themselves as spam, and the server versions pass it on untouched.
  • The hidden ref input records where the signup came from. Change its value per page or campaign.
  • With a field definition on the Fields tab, an input it doesn't list is refused with 422 and an unknown_field error, so list ref as well as email, as below.
  • A plain HTML post redirects the browser after success. Set Redirect after submit in the form's settings to your own thank-you page, or Ikiform shows its hosted one.
Fields
{
  "version": 1,
  "title": "Waitlist",
  "fields": [
    { "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
    { "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
  ]
}
The server-side versions post with a secret key, so Ikiform skips Allowed domains and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom rate limit for a form you post through a server, and keep IKIFORM_SECRET_KEY in server-only environment variables.

The server versions pass on the browser's Origin header, because Ikiform spots free localhost tests by it and a server's own fetch sends none.

Framework guides in the docs go deeper: Next.js, Astro, SvelteKit, Nuxt, React, Vue and plain HTML. The SDK wraps the browser request as submit(), and the waitlist form template generates the four-field version.

Fields worth asking for

Email is the only field you need. If you want to sort the list before launch, add at most two more. Try the four-field version below: it runs Ikiform's validator in your browser and shows what would be stored, or the 422 you'd get back.

Preview

Runs Ikiform's validator in your browser. Nothing is sent.

The waitlist template's fields plus ref, which your page fills with a hidden input rather than a visible box. Paste it on the form's Fields tab and Ikiform enforces it on the server.
FieldWhy you'd askKeep in mind
emailThe list itselfRequired. Use type="email" so phones show the right keyboard
nameA personal launch emailOptional. A first name is enough
roleInvite founders first, or write a different email to engineersA select keeps answers clean for filtering
sourceLearn which channel works before you spend on itFree text is honest but messy. A hidden ref input is cleaner, see below
refCount signups per page or campaign without askingA hidden input. Optional text with max_length 40

To track where signups come from without asking, add a hidden input per page or per campaign. Names starting with _ are instructions to Ikiform and are never stored, so use a plain name such as ref:

Hidden inputs
<input type="hidden" name="ref" value="homepage" />
<input type="hidden" name="_redirect" value="https://example.com/thanks" />

Each submission also records the page's origin and referrer, so you can see which page a signup came from even without ref. The _redirect input sends the browser to your own thank-you page; it only works for an https: URL on one of the form's Allowed domains.

If you'll send anything beyond launch news, such as a newsletter, add a required consent checkbox with the exact promise as its label. Ikiform stores true with the submission's timestamp, which records when the person agreed. The newsletter template has one.

Keep bots off the list

A public form with an email field attracts bots within days. Fake signups inflate your numbers and, worse, your launch email lands in spam traps that hurt your sender reputation. Ikiform layers four defences; the first two are on with no setup.

DefenceWhat it stopsSetup
HoneypotSimple bots that fill in every inputThe hidden _gotcha input in the form above
Spam scoringDisposable addresses (+2), identical repeats (+3), links and known spam phrases. At 5 a signup is stored as spamNone
Rate limitsScripts that post in a loopOn by default: Standard, 5 a minute per visitor, and 300 a minute for the whole form
Human checkBots that run a real browserSet Spam check to Ikiform. It runs Cloudflare Turnstile with no keys to manage

Spam is stored with a normal success answer, so a bot learns nothing. It sends no email or webhook and doesn't count toward your usage. Check the Spam filter in the dashboard once a week in the first month; if a real signup lands there, mark it as not spam and its webhook goes out then.

Add your domain under Allowed domains too. Posts from other sites are then marked as spam, or refused with 403 origin_not_allowed if you choose Reject. With the human check on, the HTML form needs no change. The fetch version must ask for a token first with challenge() from @ikiform/sdk; the spam docs show the four lines.

Launch posts arrive in bursts. If you post the page somewhere big, the form-wide limit of 300 a minute can bite before any single visitor's limit does. Raise it with a Custom rate limit the day before. A whole office or conference shares one IP, so Relaxed, 20 a minute per visitor suits those days.

The same person, twice

People sign up again from another tab, a second device, or a week later when they forget. Ikiform stores every accepted post and doesn't merge or reject repeats by email. Here's what happens to a repeat:

CaseResult
Same email, different fields or more than 24 hours laterA second, normal submission
The same fields again within 24 hours+3 to the spam score, below 5, so a second normal submission
An identical repeat from a disposable email domain+3 and +2, which reaches 5: stored as spam

So expect some duplicates, and remove them where the list ends up. Three places work, from least to most effort:

  1. In the email tool. Most email tools key contacts by address, so adding the same address twice updates one contact instead of creating two.
  2. On export. Remove duplicate emails from the CSV before you import it anywhere. Two ways are below.
  3. In your relay. Lowercase and trim the address, then insert it with a unique constraint or an upsert, so a repeat is a no-op.

Want to stop a double click from creating two rows at all? Send an idempotency key per page load with the fetch version. That stops accidental repeats; it doesn't stop a person signing up twice on purpose.

Confirming signups

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. That's deliberate: the tool that sends your launch email should also send the confirmation, so unsubscribes and bounces are tracked in one place.

Two ways to connect Ikiform to it:

  • No code. Add a webhook endpoint that points at an automation tool, then use its action for your email tool. See Zapier, Make or n8n.
  • A small relay. A route handler on any Node host verifies the signature and calls your email tool's API. The example below is a Next.js route; the same code runs in a Cloudflare Worker.
app/api/waitlist/route.ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export async function POST(request: Request) {
  const payload = await request.text();
  try {
    const event = await verifyWebhook({
      payload,
      headers: request.headers,
      secret: process.env.IKIFORM_WEBHOOK_SECRET!,
    });
    if (event.type !== "submission.created") return new Response(null, { status: 204 });
    const email = String(event.data.fields.email ?? "").trim().toLowerCase();
    if (!email) return new Response(null, { status: 204 });
    const response = await fetch(process.env.LIST_API_URL!, {
      method: "POST",
      headers: {
        authorization: `Bearer ${process.env.LIST_API_KEY}`,
        "content-type": "application/json",
      },
      body: JSON.stringify({
        email,
        name: event.data.fields.name ?? null,
        tags: ["waitlist", String(event.data.fields.role ?? "unknown")],
      }),
    });
    if (response.status === 409) return new Response(null, { status: 204 });
    if (!response.ok) return new Response(`List answered ${response.status}`, { status: 502 });
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
}
  • verifyWebhook checks the signature and the timestamp, so nobody else can add addresses through this URL.
  • Swap the fetch for your email tool's add-contact call. Most take an email and a list or tag, and send their own confirmation email when double opt-in is on in that tool.
  • Answering 204 tells Ikiform the delivery succeeded. A 502 makes Ikiform retry, with the same webhook-id, so a short outage in your email tool doesn't lose signups.

Add the endpoint under Webhooks in the dashboard and copy its whsec_ secret into IKIFORM_WEBHOOK_SECRET. A workspace can have 3 endpoints. The webhooks docs cover retries and the payload, and your own server has a version that stores each delivery once.

Launch day

A waitlist from the first signup to launch daypage liveform posts toIkiformevery weekshare it, readthe signupsa weekbeforeexport, dedupe,segmentlaunch dayone email to thelistweeksA waitlist from the first signup to launch daypage liveform posts to Ikiformevery weekshare it, read the signupsa week beforeexport, dedupe, segmentlaunch dayone email to the listweeks
  1. The page goes live with the form posting to Ikiform.
  2. Every week, share the page and read the new signups and their answers.
  3. A week before launch, export the list as CSV, remove duplicates and split it by role.
  4. On launch day, send one email to the list from your email tool.
Most of the work is the export a week out. The launch email itself is short.

Export the list

Open Submissions in the dashboard, pick the waitlist form, set the status to Received and the date range to All time, then choose Export CSV. The columns are id, created_at, form and status, then one column per field. The file is UTF-8 with a byte order mark, so accents survive Excel. One export holds up to 100,000 rows; past that, page through the REST API.

To remove duplicates in Google Sheets, import the CSV and use this formula in an empty sheet, with the email in column E:

Google Sheets
=SORTN(A2:F, 9^9, 2, LOWER(TRIM(E2:E)), TRUE)

It keeps one row per address, ignoring case and spaces, sorted by email. Or run this with Node in the folder that holds the export. It keeps the first row for each address and assumes no commas inside values, which holds for an email-only form:

dedupe.mjs
import { readFileSync, writeFileSync } from "node:fs";

const [header = "", ...rows] = readFileSync("submissions.csv", "utf8").replace(/^\uFEFF/, "").trim().split(/\r?\n/);
const columns = header.split(",");
const emailAt = columns.indexOf("email");
const seen = new Set();
const unique = rows.filter((row) => {
  const email = row.split(",")[emailAt]?.trim().toLowerCase();
  if (!email || seen.has(email)) return false;
  seen.add(email);
  return true;
});

writeFileSync("waitlist.csv", [header, ...unique].join("\n"));
console.log(`${rows.length} rows, ${unique.length} unique emails`);

Send the launch email

Send it from your email tool, not from your own inbox. A personal inbox sending to hundreds of addresses at once looks like spam to mail providers, and you'll want unsubscribe handling the moment you send. Keep it short:

Launch email
Subject: Acme is live. You're in.

Hi Ada,

You joined the Acme waitlist in the spring. Today it's open.

Sign in here: https://acme.example/start

You get the first 30 days free because you waited.
Reply to this email if anything breaks. It comes to me.

Ada, founder of Acme
  • Say what is live and link straight to the sign-up or sign-in page.
  • Give waiting a reward, such as a longer trial or a discount, and say it once.
  • Send it from a person. Replies are the best early feedback you'll get.
  • Send founders and engineers different first lines if you collected role. That's the reason to collect it.
If you collected for months without confirming addresses, send to a small slice first and watch the bounce rate. A list full of typos and dead addresses hurts the domain you'll send product email from.

Pick your situation

The steps above work everywhere. These pages cover what changes for a particular launch or site builder.

Check it works

Run through this before you share the page. Ikiform has no free plan. Testing is free: a workspace without a plan accepts 50 test submissions a month from localhost. To go live, pick Pro from $6/mo for 500 submissions, or pay once: 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire. See pricing.

  1. Under the form's Allowed domains, add your site's domain, plus localhost while you develop. Leave it empty to accept posts from anywhere.
  2. Fill in the form on your page and submit it. You land on your thank-you page, or Ikiform's hosted one.
  3. Open Submissions in the dashboard. The new submission is there with every field you sent.
  4. Check the inbox of each notification recipient. Each gets an email with the fields, and Reply goes to the submitter when the form has an email field.
  5. If you added a webhook, open the endpoint in Webhooks. The delivery shows succeeded with your server's answer.
  6. Post with the honeypot filled in, _gotcha=bot. It still gets a success answer, but the submission is stored as spam and sends no email or webhook.
  7. Nothing arrived? Switch the dashboard's status filter to Spam. Spam gets the same success answer as a real submission.
  8. Sign up twice with the same address and confirm your email tool still shows one contact.

Troubleshooting

ProblemWhat to do
403 origin_not_allowed, or every post lands in spamThe page's domain isn't in the form's Allowed domains. Add it, plus localhost for local tests. Subdomains of a listed domain are allowed.
429 rate_limitedOne visitor sent more than the form's rate limit allows. Standard allows 5 a minute per visitor. Wait for Retry-After, or choose Relaxed or Custom for forms that post from one shared IP.
403 challenge_requiredThe form's spam check is set to Ikiform and a fetch post had no token. Get one with challenge(formId) from @ikiform/sdk before each submit, or turn the check off.
The submission is in the dashboard but no email or webhook arrivedIt was stored as spam. Spam sends nothing. Open it under the Spam filter to see the reasons, and mark it as not spam to send its email and webhooks.
The browser lands on Ikiform's thank-you page instead of yours_redirect is ignored unless its domain is in Allowed domains and it is an https: URL. Add the domain, or set Redirect after submit in the form's settings instead.
The same email shows up twiceExpected. Ikiform doesn't deduplicate by email. Remove repeats on export, or upsert by email in your relay or email tool.
Signups arrive in Ikiform but not in the email toolOpen the endpoint under Webhooks and read the delivery log. A 400 means the secret is wrong; a 502 with your tool's status means the tool refused the contact.
402 test_limit_reachedThe workspace has no plan and used its 50 free localhost tests this month. Pick a plan, or use Send test in the dashboard.

Questions

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.