Guias19 min de leitura
Como criar uma lista de espera para um novo SaaS
Monte uma lista de espera pré-lançamento na sua própria landing page: o formulário, proteção contra spam, duplicados, e-mails de confirmação e uma exportação limpa no dia do lançamento.
Coloque um formulário HTML simples na sua landing page que envia o e-mail do visitante a um backend de formulários, para você não precisar rodar um servidor antes do lançamento. Encaminhe cada inscrição por webhook para a ferramenta de e-mail com que você vai lançar, e deixe essa ferramenta enviar qualquer confirmação. No dia do lançamento, exporte a lista como CSV, remova os duplicados por e-mail e envie um único e-mail de lançamento.
Uma lista de espera é o teste mais barato de uma ideia de SaaS. Se desconhecidos deixam o e-mail por algo que ainda não existe, você tem um motivo para construir, e uma lista de pessoas para avisar quando sair. Este guia monta uma na sua própria landing page, sem banco de dados nem servidor, e a deixa pronta para o dia do lançamento.
Como funciona uma lista de espera
Toda lista de espera tem as mesmas três partes: um formulário na sua página, um lugar que armazena cada inscrição e a ferramenta pela qual você vai mandar e-mail para a lista. Aqui, o formulário envia ao Ikiform. O Ikiform verifica se o envio é spam, armazena, avisa você por e-mail e envia um webhook assinado. Um pequeno relay ou uma ferramenta de automação entrega o endereço à sua ferramenta de e-mail.
- Um visitante digita o e-mail na sua landing page, e o formulário envia ao seu endpoint do Ikiform.
- O Ikiform confere o honeypot, a pontuação de spam e o limite de taxa, e então armazena a inscrição.
- A inscrição aparece no painel, onde você pode pesquisá-la e exportar a lista como CSV.
- O Ikiform envia e-mail para você e sua equipe. Ele nunca envia e-mail para quem se inscreveu.
- O Ikiform envia um webhook submission.created assinado.
- Um relay ou uma ferramenta de automação adiciona o endereço à sua ferramenta de e-mail, que envia qualquer confirmação e, depois, o e-mail de lançamento.
Quatro formas de rodar uma lista de espera
Não existe uma única ferramenta certa. A tabela compara as quatro configurações mais comuns pelo que fazem bem e pelo que custam depois.
| Abordagem | Bom em | Contras |
|---|---|---|
| SaaS de lista de espera, como LaunchList ou Waitlister | Links de indicação, rankings e posição na fila, prontos para usar. Uma página hospedada se você ainda não tem site. | Sua lista fica em outro produto. O widget tem a cara deles, a menos que você pague ou mude o estilo. O preço geralmente cresce com as inscrições. |
| Formulário incorporado de um criador de formulários, como Typeform ou Tally | Fluxos caprichados de uma pergunta, sem nenhum código. | Um iframe que não combina com a sua página e carrega scripts próprios. Marca, limites de respostas e webhooks dependem do plano. |
| Seu próprio banco de dados e rota de API | Controle total sobre os dados e qualquer lógica, como códigos de indicação. | Você constrói proteção contra spam, limites de taxa, notificações e exportação, e precisa de um servidor mesmo que a landing page seja estática. |
| Backend de formulários, como o Ikiform | Seu próprio HTML em qualquer site. Filtro de spam, armazenamento, alertas por e-mail, webhooks assinados e exportação CSV sem servidor. | Sem ranking de indicações e sem e-mails para quem se inscreve. Isso vem da sua ferramenta de e-mail. |
Escolha um SaaS de lista de espera se a mecânica de indicações é o plano inteiro. Escolha um backend de formulários se você quer que o formulário tenha a cara do resto do seu site, que a lista seja portátil e que os e-mails saiam da ferramenta que você vai usar depois do lançamento de qualquer jeito. O resto deste guia usa o backend de formulários.
O formulário
Para começar, peça um e-mail e nada mais. Cada campo a mais custa inscrições, e você pode fazer perguntas depois, por e-mail, a quem já disse sim. Escolha sua stack abaixo; a escolha vale para todos os exemplos de código destes guias.
Cole o formulário onde a inscrição deve aparecer. Ele envia do jeito tradicional e redireciona, então funciona sem nenhum JavaScript.
<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>Para manter os visitantes na página, adicione este script depois do formulário. Ele envia com fetch, troca o formulário pela frase de agradecimento e mostra os erros de campo do servidor.
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;
});Quer ir além com HTML? Leia Como adicionar um formulário de lista de espera em HTML puro a qualquer landing page.
Uma server action encaminha a inscrição com a sua chave secreta, e useActionState mostra o resultado sem recarregar a página. O formulário ainda envia se o JavaScript não tiver carregado.
"use server";
import { headers } from "next/headers";
export interface JoinState {
ok: boolean;
message: string;
email?: string;
}
export async function joinWaitlist(_previous: JoinState, formData: FormData): Promise<JoinState> {
const email = String(formData.get("email") ?? "").trim();
if (!email.includes("@")) return { ok: false, message: "Enter the email we should send your invite to.", email };
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
Origin: (await headers()).get("origin") ?? "",
},
body: JSON.stringify({
email,
ref: String(formData.get("ref") ?? "homepage"),
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { ok: true, message: "You're on the list. We'll email you on launch day." };
return {
ok: false,
message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
email,
};
}"use client";
import { useActionState } from "react";
import { type JoinState, joinWaitlist } from "./actions";
const initial: JoinState = { ok: false, message: "" };
export function WaitlistForm() {
const [state, action, pending] = useActionState(joinWaitlist, initial);
if (state.ok) return <p role="status">{state.message}</p>;
return (
<form action={action}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required defaultValue={state.email} placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.message ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</form>
);
}Quer ir além com Next.js? Leia Como montar uma lista de espera em Next.js com uma server action.
Uma Astro Action com accept: "form" valida o e-mail com Zod e o encaminha. O formulário envia sem JavaScript no cliente, então a página precisa ser renderizada sob demanda: adicione um adapter e mantenha prerender = false.
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
joinWaitlist: defineAction({
accept: "form",
input: z.object({
email: z.email(),
ref: z.string().default("homepage"),
_gotcha: z.string().nullish(),
}),
handler: async (input, context) => {
const origin = context.request.headers.get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${getSecret("IKIFORM_SECRET_KEY")}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(input),
});
const result = await response.json();
if (!result.ok) {
throw new ActionError({
code: "BAD_REQUEST",
message: result.error.fields?.email?.[0] ?? result.error.message,
});
}
return { joined: true };
},
}),
};---
import { actions, isInputError } from "astro:actions";
export const prerender = false;
const result = Astro.getActionResult(actions.joinWaitlist);
---
{result?.data?.joined ? (
<p role="status">You're on the list. We'll email you on launch day.</p>
) : (
<form method="POST" action={actions.joinWaitlist}>
<label for="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{result?.error && (
<p role="alert">{isInputError(result.error) ? "Enter a valid email address." : result.error.message}</p>
)}
<button>Join the waitlist</button>
</form>
)}Quer ir além com Astro? Leia Monte uma lista de espera em Astro.
Uma função POST createServerFn recebe o FormData, confere em .validator() e encaminha a partir do servidor. A rota a chama via useServerFn.
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
const joinWaitlist = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
const email = String(data.get("email") ?? "").trim();
if (!email.includes("@")) throw new Error("Enter the email we should send your invite to.");
return {
email,
ref: String(data.get("ref") ?? "homepage"),
_gotcha: String(data.get("_gotcha") ?? ""),
};
})
.handler(async ({ data }) => {
const origin = getRequestHeader("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(data),
});
const result = await response.json();
if (result.ok) return { ok: true, message: "You're on the list. We'll email you on launch day." };
return { ok: false, message: String(result.error.fields?.email?.[0] ?? result.error.message) };
});
export const Route = createFileRoute("/")({ component: Home });
function Home() {
const join = useServerFn(joinWaitlist);
const [state, setState] = useState<{ ok: boolean; message: string } | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setPending(true);
try {
setState(await join({ data }));
} catch (error) {
setState({ ok: false, message: error instanceof Error ? error.message : "Couldn't join the list. Try again." });
} finally {
setPending(false);
}
}
if (state?.ok) return <p role="status">{state.message}</p>;
return (
<form method="post" onSubmit={onSubmit}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state?.message ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</form>
);
}Quer ir além com TanStack Start? Leia Monte uma lista de espera em TanStack Start.
A action da rota encaminha o envio, e actionData leva o resultado de volta ao componente. data() mantém o status code do Ikiform nos erros.
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/home";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const email = String(formData.get("email") ?? "").trim();
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
Origin: request.headers.get("origin") ?? "",
},
body: JSON.stringify({
email,
ref: String(formData.get("ref") ?? "homepage"),
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (result.ok) return { ok: true, message: "You're on the list. We'll email you on launch day." };
return data(
{ ok: false, message: String(result.error.fields?.email?.[0] ?? result.error.message) },
{ status: response.status },
);
}
export default function Home({ actionData }: Route.ComponentProps) {
const navigation = useNavigation();
const pending = navigation.state === "submitting";
if (actionData?.ok) return <p role="status">{actionData.message}</p>;
return (
<Form method="post">
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{actionData?.message ? <p role="alert">{actionData.message}</p> : null}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</Form>
);
}Quer ir além com React Router? Leia Monte uma lista de espera em React Router.
Uma form action padrão encaminha o envio e devolve fail() com a mensagem nos erros. use:enhance envia sem recarregar e mantém o e-mail digitado no input. No SvelteKit 3, declare IKIFORM_SECRET_KEY em src/env.ts com defineEnvVars para que $app/env/private consiga lê-la.
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";
export const actions = {
default: async ({ request, fetch, url }) => {
const data = await request.formData();
const email = String(data.get("email") ?? "").trim();
if (!email.includes("@")) return fail(400, { email, message: "Enter the email we should send your invite to." });
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
Origin: request.headers.get("origin") ?? url.origin,
},
body: JSON.stringify({
email,
ref: String(data.get("ref") ?? "homepage"),
_gotcha: String(data.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (!result.ok) {
return fail(response.status, {
email,
message: String(result.error.fields?.email?.[0] ?? result.error.message),
});
}
return { joined: true };
},
} satisfies Actions;<script lang="ts">
import { enhance } from "$app/forms";
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
let pending = $state(false);
</script>
{#if form?.joined}
<p role="status">You're on the list. We'll email you on launch day.</p>
{:else}
<form
method="POST"
use:enhance={() => {
pending = true;
return async ({ update }) => {
await update();
pending = false;
};
}}
>
<label for="email">Email</label>
<input id="email" name="email" type="email" required value={form?.email ?? ""} placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if form?.message}
<p role="alert">{form.message}</p>
{/if}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</form>
{/if}Quer ir além com SvelteKit? Leia Monte uma lista de espera em SvelteKit.
Uma server route mantém a chave secreta longe do navegador. Defina NUXT_IKIFORM_SECRET_KEY no ambiente, e o Nuxt preenche runtimeConfig.ikiformSecretKey com ela.
export default defineEventHandler(async (event) => {
const body = await readBody<{ email?: string; ref?: string; _gotcha?: string }>(event);
const config = useRuntimeConfig(event);
const origin = getRequestHeader(event, "origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${config.ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
email: body.email?.trim() ?? "",
ref: body.ref ?? "homepage",
_gotcha: body._gotcha ?? "",
}),
});
const result = await response.json();
if (!result.ok) {
throw createError({
status: response.status,
message: String(result.error.fields?.email?.[0] ?? result.error.message),
});
}
return { joined: true };
});export default defineNuxtConfig({
runtimeConfig: {
ikiformSecretKey: "",
},
});<script setup lang="ts">
const email = ref("");
const trap = ref("");
const status = ref<"idle" | "sending" | "joined">("idle");
const error = ref("");
async function join() {
status.value = "sending";
error.value = "";
try {
await $fetch("/api/waitlist", {
method: "POST",
body: { email: email.value, ref: "homepage", _gotcha: trap.value },
});
status.value = "joined";
} catch (failure) {
error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't join the list. Try again.";
status.value = "idle";
}
}
</script>
<template>
<p v-if="status === 'joined'" role="status">You're on the list. We'll email you on launch day.</p>
<form v-else @submit.prevent="join">
<label for="email">Email</label>
<input id="email" v-model="email" name="email" type="email" required placeholder="you@company.com" />
<input v-model="trap" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Joining" : "Join the waitlist" }}</button>
</form>
</template>Quer ir além com Nuxt? Leia Monte uma lista de espera em Nuxt.
Uma single-page app não tem servidor próprio, então o formulário envia direto ao Ikiform a partir do navegador. Nunca coloque uma chave secreta neste código; quem protege o formulário é Allowed domains.
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export function WaitlistForm() {
const [status, setStatus] = useState<"idle" | "sending" | "joined">("idle");
const [error, setError] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = new FormData(event.currentTarget);
setStatus("sending");
setError("");
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
setStatus("joined");
return;
}
setError(result.error.fields?.email?.[0] ?? result.error.message);
} catch {
setError("Couldn't reach the server. Try again.");
}
setStatus("idle");
}
if (status === "joined") return <p role="status">You're on the list. We'll email you on launch day.</p>;
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{error ? <p role="alert">{error}</p> : null}
<button disabled={status === "sending"}>{status === "sending" ? "Joining" : "Join the waitlist"}</button>
</form>
);
}O mesmo envio pelo navegador num single-file component do Vue. O formulário mantém o action, então ainda funciona se o script não carregar.
<script setup lang="ts">
import { ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const status = ref<"idle" | "sending" | "joined">("idle");
const error = ref("");
async function join(event: SubmitEvent) {
const body = new FormData(event.target as HTMLFormElement);
status.value = "sending";
error.value = "";
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
status.value = "joined";
return;
}
error.value = result.error.fields?.email?.[0] ?? result.error.message;
} catch {
error.value = "Couldn't reach the server. Try again.";
}
status.value = "idle";
}
</script>
<template>
<p v-if="status === 'joined'" role="status">You're on the list. We'll email you on launch day.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="join">
<label for="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Joining" : "Join the waitlist" }}</button>
</form>
</template>- O endpoint é a URL do seu formulário. Troque
frm_xxxxxxxxxxxxxxxxpelo id que está no painel. name="email"é o id do campo. Mantenha comoemail: o Ikiform o usa como endereço de reply-to dos e-mails de notificação e o confere contra domínios de e-mail descartáveis.- O input oculto
_gotchaé o honeypot. As pessoas nunca o veem. Bots que preenchem todos os inputs se marcam como spam, e as versões no servidor o repassam sem mexer. - O input oculto
refregistra de onde veio a inscrição. Mude o valor dele por página ou campanha. - Com uma definição de campos na aba Fields, um input que ela não lista é recusado com
422e um errounknown_field, então listerefalém deemail, como abaixo. - Um envio de HTML simples redireciona o navegador depois do sucesso. Defina Redirect after submit nas configurações do formulário com a sua própria página de agradecimento, ou o Ikiform mostra a página hospedada dele.
{
"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 } }
]
}IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.As versões no servidor repassam o header Origin do navegador, porque o Ikiform identifica os testes gratuitos de localhost por ele e o fetch do próprio servidor não envia nenhum.
Os guias de frameworks na documentação vão mais a fundo: Next.js, Astro, SvelteKit, Nuxt, React, Vue e HTML puro. O SDK encapsula a requisição do navegador como submit(), e o modelo de formulário de lista de espera gera a versão com quatro campos.
Campos que valem a pena pedir
O e-mail é o único campo de que você precisa. Se quiser organizar a lista antes do lançamento, adicione no máximo mais dois. Experimente a versão com quatro campos abaixo: ela roda o validador do Ikiform no seu navegador e mostra o que seria armazenado, ou o 422 que você receberia de volta.
Prévia
Roda o validador do Ikiform no seu navegador. Nada é enviado.
ref, que a sua página preenche com um input oculto em vez de uma caixa visível. Cole na aba Fields do formulário e o Ikiform aplica tudo no servidor.| Campo | Por que perguntar | Lembre-se |
|---|---|---|
email | A lista em si | Obrigatório. Use type="email" para os celulares mostrarem o teclado certo |
name | Um e-mail de lançamento personalizado | Opcional. O primeiro nome basta |
role | Convidar fundadores primeiro, ou escrever um e-mail diferente para engenheiros | Um select mantém as respostas limpas para filtrar |
source | Descobrir qual canal funciona antes de gastar com ele | Texto livre é sincero, mas bagunçado. Um input oculto ref é mais limpo, veja abaixo |
ref | Contar as inscrições por página ou campanha sem perguntar | Um input oculto. text opcional com max_length 40 |
Para acompanhar de onde vêm as inscrições sem perguntar, adicione um input oculto por página ou por campanha. Nomes que começam com _ são instruções para o Ikiform e nunca são armazenados, então use um nome simples como ref:
<input type="hidden" name="ref" value="homepage" />
<input type="hidden" name="_redirect" value="https://example.com/thanks" />Cada envio também registra a origem e o referrer da página, então você vê de qual página veio uma inscrição mesmo sem ref. O input _redirect manda o navegador para a sua própria página de agradecimento; ele só funciona com uma URL https: em um dos Allowed domains do formulário.
true com o horário do envio, o que registra quando a pessoa concordou. O modelo de inscrição em newsletter tem um.Mantenha os bots fora da lista
Um formulário público com campo de e-mail atrai bots em poucos dias. Inscrições falsas inflam seus números e, pior, seu e-mail de lançamento cai em spam traps que prejudicam a reputação do remetente. O Ikiform empilha quatro defesas; as duas primeiras já vêm ligadas, sem configuração.
| Defesa | O que ela barra | Configuração |
|---|---|---|
| Honeypot | Bots simples que preenchem todos os inputs | O input oculto _gotcha no formulário acima |
| Pontuação de spam | Endereços descartáveis (+2), repetições idênticas (+3), links e frases de spam conhecidas. Com 5, uma inscrição é armazenada como spam | Nenhuma |
| Limites de taxa | Scripts que enviam em loop | Ligado por padrão: Standard, 5 por minuto por visitante, e 300 por minuto para o formulário inteiro |
| Verificação humana | Bots que rodam um navegador de verdade | Defina Spam check como Ikiform. Ele roda o Cloudflare Turnstile sem chaves para gerenciar |
Spam é armazenado com uma resposta de sucesso normal, então o bot não aprende nada. Ele não dispara e-mail nem webhook e não conta no seu uso. Confira o filtro Spam no painel uma vez por semana no primeiro mês; se uma inscrição real cair lá, marque-a como não spam e o webhook dela é enviado na hora.
Adicione também o seu domínio em Allowed domains. Envios de outros sites passam a ser marcados como spam, ou recusados com 403 origin_not_allowed se você escolher Reject. Com a verificação humana ligada, o formulário HTML não precisa de mudança. A versão com fetch precisa pedir um token antes com challenge() de @ikiform/sdk; a documentação de spam mostra as quatro linhas.
A mesma pessoa, duas vezes
As pessoas se inscrevem de novo por outra aba, por um segundo dispositivo ou uma semana depois, quando esquecem. O Ikiform armazena todo envio aceito e não mescla nem rejeita repetições pelo e-mail. Veja o que acontece com uma repetição:
| Caso | Resultado |
|---|---|
| Mesmo e-mail, campos diferentes ou mais de 24 horas depois | Um segundo envio normal |
| Os mesmos campos de novo em até 24 horas | +3 na pontuação de spam, abaixo de 5, então um segundo envio normal |
| Uma repetição idêntica de um domínio de e-mail descartável | +3 e +2, o que chega a 5: armazenado como spam |
Então espere alguns duplicados e remova-os onde a lista for parar. Três lugares funcionam, do menor para o maior esforço:
- Na ferramenta de e-mail. A maioria das ferramentas de e-mail identifica os contatos pelo endereço, então adicionar o mesmo endereço duas vezes atualiza um contato em vez de criar dois.
- Na exportação. Remova os e-mails duplicados do CSV antes de importá-lo em qualquer lugar. Há duas formas logo abaixo.
- No seu relay. Passe o endereço para minúsculas e tire os espaços, depois insira-o com uma restrição de unicidade ou um upsert, para que uma repetição não faça nada.
Quer evitar que um clique duplo crie duas linhas? Envie uma chave de idempotência por carregamento de página com a versão fetch. Isso impede repetições acidentais; não impede alguém de se inscrever duas vezes de propósito.
Confirmando as inscrições
O Ikiform não envia e-mail para quem preenche o formulário. Os e-mails de notificação vão só para você e sua equipe, então não há resposta automática, e-mail de confirmação nem double opt-in. Envie esses e-mails pela sua ferramenta de e-mail, disparados por um webhook. Isso é proposital: a ferramenta que envia o seu e-mail de lançamento também deve enviar a confirmação, para que descadastros e devoluções fiquem registrados num lugar só.
Duas formas de conectar o Ikiform a ela:
- Sem código. Adicione um endpoint de webhook que aponta para uma ferramenta de automação e use a ação dela para a sua ferramenta de e-mail. Veja Zapier, Make ou n8n.
- Um pequeno relay. Um route handler em qualquer host Node verifica a assinatura e chama a API da sua ferramenta de e-mail. O exemplo abaixo é uma rota do Next.js; o mesmo código roda num Cloudflare Worker.
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;
}
}verifyWebhookconfere a assinatura e o timestamp, então ninguém mais consegue adicionar endereços por esta URL.- Troque o
fetchpela chamada de adicionar contato da sua ferramenta de e-mail. A maioria recebe um e-mail e uma lista ou tag, e envia o próprio e-mail de confirmação quando o double opt-in está ligado naquela ferramenta. - Responder
204diz ao Ikiform que a entrega deu certo. Um502faz o Ikiform tentar de novo, com o mesmowebhook-id, então uma instabilidade curta na sua ferramenta de e-mail não faz você perder inscrições.
Adicione o endpoint em Webhooks no painel e copie o segredo whsec_ dele para IKIFORM_WEBHOOK_SECRET. Um workspace pode ter 3 endpoints. A documentação de webhooks explica as novas tentativas e o payload, e seu próprio servidor tem uma versão que armazena cada entrega uma única vez.
Dia do lançamento
- A página entra no ar com o formulário enviando ao Ikiform.
- Toda semana, divulgue a página e leia as novas inscrições e as respostas.
- Uma semana antes do lançamento, exporte a lista como CSV, remova os duplicados e divida por função.
- No dia do lançamento, envie um único e-mail para a lista pela sua ferramenta de e-mail.
Exporte a lista
Abra Submissions no painel, escolha o formulário da lista de espera, defina o status como Received e o período como All time, e então escolha Export CSV. As colunas são id, created_at, form e status, e depois uma coluna por campo. O arquivo é UTF-8 com byte order mark, então os acentos sobrevivem ao Excel. Uma exportação tem até 100.000 linhas; passando disso, pagine pela API REST.
Para remover os duplicados no Google Sheets, importe o CSV e use esta fórmula numa aba vazia, com o e-mail na coluna E:
=SORTN(A2:F, 9^9, 2, LOWER(TRIM(E2:E)), TRUE)Ela mantém uma linha por endereço, ignorando maiúsculas e espaços, ordenada por e-mail. Ou rode isto com Node na pasta que contém a exportação. Ele mantém a primeira linha de cada endereço e supõe que não há vírgulas dentro dos valores, o que vale para um formulário só com e-mail:
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`);Envie o e-mail de lançamento
Envie pela sua ferramenta de e-mail, não pela sua caixa pessoal. Uma caixa pessoal mandando para centenas de endereços de uma vez parece spam para os provedores de e-mail, e você vai querer o tratamento de descadastros a partir do momento em que enviar. Seja breve:
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- Diga o que está no ar e coloque o link direto para a página de cadastro ou de login.
- Recompense a espera, com um trial mais longo ou um desconto, e diga isso uma vez só.
- Envie em nome de uma pessoa. As respostas são o melhor feedback inicial que você vai ter.
- Mande primeiras linhas diferentes para fundadores e engenheiros se você coletou
role. É por isso que vale coletar.
Escolha o seu caso
Os passos acima funcionam em qualquer lugar. Estas páginas mostram o que muda para um lançamento ou um criador de sites específico.
- Lista para lançamento no Product HuntColete uma lista semanas antes do Product Hunt, marque cada inscrição pelo canal e envie o e-mail do dia do lançamento em duas ondas, no ritmo da virada do horário do Pacífico.
- Inscrição para beta com triagemUm formulário de candidatura ao beta com perguntas de triagem que o servidor exige, e um script que pontua os candidatos e escolhe cada turma da lista.
- Lista de espera para app mobileUma lista de espera pré-lançamento para um app iOS ou Android que pergunta qual celular a pessoa usa, manda testadores de iPhone ao TestFlight e preenche um teste fechado do Play.
- Lista de espera com Next.jsUma lista de espera no App Router do Next.js: uma server action que encaminha as inscrições com chave secreta, useActionState, agradecimento otimista e limite por visitante.
- Lista de espera no FramerDuas formas de coletar inscrições na lista de espera no Framer: o formulário do Framer com webhook para o Ikiform, ou um code component que mantém os limites por visitante e as verificações de spam.
- Lista de espera no WebflowAponte um Form Block do Webflow para o Ikiform, marque as inscrições pela origem com um campo oculto e mantenha o estado de sucesso do Webflow com um script curto para todos os formulários.
- Lista de espera no CarrdColete inscrições na lista de espera numa página do Carrd: um formulário Send to URL no Pro Plus com rastreamento de UTM, ou um formulário HTML incorporado com honeypot no Pro Standard.
- Lista de espera em HTML puroUma página de lista de espera completa em HTML puro, sem build, que funciona com o JavaScript desligado, mostra um agradecimento na própria página quando carrega e tem plano B quando precisa.
- Lista de espera com AstroUma lista de espera em Astro numa landing page estática: uma Action encaminha cada inscrição, um script a chama e uma página sob demanda pega os envios sem JavaScript.
- Lista de espera com TanStack StartUma lista de espera em TanStack Start: uma server route responde tanto a um envio de formulário simples quanto ao fetch, e o validateSearch leva a origem e o resultado na URL.
- Lista de espera com React RouterUma lista de espera em React Router no rodapé de todas as páginas: um fetcher envia para uma única route action, marca cada inscrição com a página e funciona sem JavaScript.
- Lista de espera com SvelteKitUma lista de espera em SvelteKit 3: um hook guarda o primeiro ?ref= num cookie, uma form action nomeada encaminha a inscrição e quem volta vê que já se inscreveu.
- Lista de espera com NuxtUma lista de espera em Nuxt numa landing page pré-renderizada: uma rota Nitro aceita $fetch e envios de formulário simples, valida com Zod e redireciona sem JavaScript.
Confira se funciona
Siga estes passos antes de divulgar a página. O Ikiform não tem plano gratuito. Testar é grátis: um workspace sem plano aceita 50 envios de teste por mês a partir de localhost. Para entrar no ar, escolha o Pro a partir de US$ 6/mês para 500 envios, ou pague uma vez: 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram. Veja os preços.
- Em Allowed domains do formulário, adicione o domínio do seu site, mais
localhostenquanto desenvolve. Deixe vazio para aceitar envios de qualquer lugar. - Preencha o formulário na sua página e envie. Você cai na sua página de agradecimento, ou na hospedada pelo Ikiform.
- Abra Submissions no painel. O novo envio está lá com todos os campos que você enviou.
- Confira a caixa de entrada de cada destinatário das notificações. Cada um recebe um e-mail com os campos, e Reply vai para quem enviou quando o formulário tem um campo
email. - Se você adicionou um webhook, abra o endpoint em Webhooks. A entrega mostra
succeededcom a resposta do seu servidor. - Envie com o honeypot preenchido,
_gotcha=bot. Ele ainda recebe uma resposta de sucesso, mas o envio é armazenado como spam e não dispara e-mail nem webhook. - Não chegou nada? Mude o filtro de status do painel para Spam. Spam recebe a mesma resposta de sucesso de um envio real.
- Inscreva-se duas vezes com o mesmo endereço e confirme que a sua ferramenta de e-mail continua mostrando um único contato.
Solução de problemas
| Problema | O que fazer |
|---|---|
403 origin_not_allowed, ou todo envio cai no spam | O domínio da página não está em Allowed domains do formulário. Adicione-o, mais localhost para testes locais. Subdomínios de um domínio listado são permitidos. |
429 rate_limited | Um visitante enviou mais do que o limite de taxa do formulário permite. O Standard permite 5 por minuto por visitante. Espere o Retry-After, ou escolha Relaxed ou Custom para formulários que enviam a partir de um único IP compartilhado. |
403 challenge_required | A verificação de spam do formulário está como Ikiform e um envio por fetch não tinha token. Obtenha um com challenge(formId) de @ikiform/sdk antes de cada envio, ou desligue a verificação. |
| O envio está no painel, mas nenhum e-mail ou webhook chegou | Ele foi armazenado como spam. Spam não envia nada. Abra-o no filtro Spam para ver os motivos e marque-o como não spam para enviar o e-mail e os webhooks dele. |
| O navegador cai na página de agradecimento do Ikiform em vez da sua | O _redirect é ignorado, a menos que o domínio dele esteja em Allowed domains e ele seja uma URL https:. Adicione o domínio, ou defina Redirect after submit nas configurações do formulário. |
| O mesmo e-mail aparece duas vezes | É o esperado. O Ikiform não remove duplicados por e-mail. Remova as repetições na exportação, ou faça upsert por e-mail no seu relay ou na sua ferramenta de e-mail. |
| As inscrições chegam no Ikiform, mas não na ferramenta de e-mail | Abra o endpoint em Webhooks e leia o log de entregas. Um 400 significa que o segredo está errado; um 502 com o status da sua ferramenta significa que a ferramenta recusou o contato. |
402 test_limit_reached | O workspace não tem plano e já usou os 50 testes gratuitos de localhost deste mês. Escolha um plano ou use Send test no painel. |
Perguntas
Nenhum servidor próprio. O formulário precisa de um lugar para onde enviar, e é isso que um backend de formulários é: aponte o action do formulário para o seu endpoint do Ikiform e ele armazena, filtra e encaminha cada inscrição. Sua landing page pode continuar estática.
Não. O Ikiform envia e-mail para você e sua equipe, não para quem se inscreve, então não há e-mail de confirmação nem de double opt-in. Envie cada inscrição por webhook para a sua ferramenta de e-mail e ligue o double opt-in lá.
Mantenha o honeypot oculto _gotcha, deixe o limite de taxa padrão ligado, adicione seu domínio em Allowed domains e defina a verificação de spam como Ikiform para ter uma verificação do Cloudflare Turnstile sem chaves para gerenciar. Endereços descartáveis também somam na pontuação de spam.
Pode ser. O Ikiform guarda todo envio aceito e não mescla repetições pelo e-mail. Remova os duplicados onde a lista fica: na sua ferramenta de e-mail, numa coluna única do seu banco de dados ou no CSV antes de importá-lo.
Não só com o Ikiform. Ele não tem links de indicação nem rankings. Você pode passar um código de indicação num campo oculto e contar você mesmo, ou usar um produto de lista de espera feito em torno de indicações, se isso for central no seu lançamento.
Não, não há plano gratuito. Testar é grátis: você pode enviar 50 envios de teste por mês a partir de localhost antes de pagar. Para entrar no ar, o Pro começa em US$ 6/mês para 500 envios, ou você pode pagar uma vez 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.