Guias18 min de leitura
Monte um formulário de captação de leads que chegue rápido até você
Um formulário de leads no seu próprio site que envia e-mail com reply-to, avisa o chat da equipe e alimenta seu CRM, para você responder enquanto o lead ainda está lá.
Coloque um formulário curto na sua própria página que envia nome, e-mail de trabalho e uma pergunta de qualificação a um backend de formulários. Ative os e-mails de notificação para que cada lead chegue à sua caixa de entrada com o Reply apontado para o lead, e envie um webhook assinado para o chat da equipe e para o seu CRM. Depois meça os minutos entre o envio e a primeira resposta, porque esse número decide mais negócios do que o design do formulário.
Um formulário de leads é um formulário de contato com uma missão: começar uma conversa de vendas. Quem o preenche está comparando você com outra empresa, muitas vezes em outra aba. A equipe que responde primeiro, com uma resposta útil de uma pessoa de verdade, geralmente fica com a reunião. Este guia monta o formulário no seu próprio site, faz cada lead chegar até você em segundos e deixa o CRM como opcional.
Como um lead chega até você
O formulário envia ao Ikiform. O Ikiform verifica se o envio é spam, armazena e então faz três coisas ao mesmo tempo: manda um e-mail para a sua equipe com o lead como endereço de reply-to, envia um webhook assinado e mostra o lead no painel. O webhook é como o lead chega ao chat da equipe e ao seu CRM.
- Um visitante preenche o formulário de leads no seu site, e ele 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 o lead.
- Segundos depois, cada destinatário das notificações recebe um e-mail, e clicar em Reply escreve para o lead.
- O lead aparece no painel, onde você pode pesquisá-lo, filtrá-lo e exportá-lo.
- O Ikiform envia um webhook submission.created assinado.
- Um relay ou uma ferramenta de automação publica o lead no chat da equipe e cria o contato no seu CRM.
Quatro formas de captar leads
Cada configuração comum é boa em alguma coisa. Escolha pelo lugar onde você quer que os leads fiquem e por quem mantém o formulário.
| Abordagem | Bom em | Contras |
|---|---|---|
| O formulário do próprio CRM, como os formulários do HubSpot ou os web forms do Pipedrive | Os leads chegam ao CRM como contatos sem nenhuma integração extra. Rastreamento e lead scoring ficam num único produto. | O formulário é incorporado com o script e o estilo do CRM. Trocar de CRM significa refazer todos os formulários. Alguns recursos dependem do plano. |
| Formulário incorporado de um criador de formulários, como Typeform ou Tally | Formulários em várias etapas caprichados, com lógica e sem código. | Um embed com a cara do criador, a menos que você pague para mudar o estilo. Webhooks e limites de respostas dependem do plano. |
| Sua própria rota de API e banco de dados | Qualquer lógica que você quiser, como lead scoring antes de armazenar qualquer coisa. | Você constrói filtro de spam, limites de taxa, alertas por e-mail, novas tentativas e exportação, e mantém um servidor rodando para um único formulário. |
| Backend de formulários, como o Ikiform | Seu próprio HTML em qualquer site. Filtro de spam, alertas por e-mail com reply-to, webhooks assinados e exportação CSV sem servidor. | Sem lead scoring, sem registros no CRM e sem e-mail para o lead. Isso vem do seu CRM ou da sua ferramenta de e-mail, via webhook. |
Se todo o seu processo de vendas roda dentro de um CRM e você está satisfeito com o formulário dele, use-o. Um backend de formulários faz sentido quando o formulário precisa combinar com o seu site, quando você pode trocar de CRM ou quando quer que o mesmo formulário chegue ao e-mail, ao chat e a um CRM sem ter que escolher um só.
Pergunte menos, qualifique uma vez
Cada campo que você adiciona custa envios. A maioria dos formulários de leads precisa de quatro coisas: para quem responder, onde a pessoa trabalha, o que ela quer e espaço para explicar. Uma única pergunta de escolha fixa faz a qualificação, porque chega como um de poucos valores conhecidos em que o seu roteamento pode confiar. Teste abaixo; ele roda o validador do Ikiform no seu navegador e mostra o que seria armazenado, ou o 422 que você receberia.
Prévia
Roda o validador do Ikiform no seu navegador. Nada é enviado.
| Campo | Por quê | Lembre-se |
|---|---|---|
name | Uma resposta que começa pelo nome da pessoa | Mantenha o id name. Um link no campo de nome é um forte sinal de spam |
email | Para quem o Reply escreve | Mantenha o id email. Rotulá-lo como Work email (e-mail de trabalho) incentiva as pessoas a usar um, mas não obriga |
company | Pesquisar a pessoa antes de responder | Opcional. Torná-lo obrigatório custa envios de freelancers |
interest | Roteamento e prioridade | Um select armazena um dos valores listados ou recusa o envio, então as automações podem se ramificar com base nele |
message | Contexto para a primeira resposta | Opcional. Uma caixa de texto longo obrigatória é o campo em que as pessoas desistem |
Telefone, tamanho da equipe e orçamento pertencem a formulários em que eles mudam o que você faz em seguida. O modelo de pedido de orçamento é a versão mais completa, com serviço, orçamento e prazo como escolhas fixas. Se um vendedor liga para todo lead, peça o telefone. Se você roteia por tamanho de equipe, peça isso como um select. Caso contrário, deixe para a primeira resposta.
O formulário
Escolha sua stack. Toda versão mostra a mensagem do servidor embaixo do campo a que ela se refere e mantém o que o visitante digitou, porque perder uma mensagem escrita pela metade por causa de um erro de digitação é como os leads desistem. O estado de sucesso diz quem responde e quando, para ninguém ficar em dúvida se funcionou.
O formulário envia do jeito tradicional e redireciona para a sua página de agradecimento, então funciona antes de qualquer script carregar. Cada campo tem um espaço vazio para o seu erro.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" id="lead-form" novalidate>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
<p class="error" data-error-for="name" hidden></p>
<label for="email">Work email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<p class="error" data-error-for="email" hidden></p>
<label for="company">Company</label>
<input id="company" name="company" autocomplete="organization" />
<label for="interest">What can we help with?</label>
<select id="interest" name="interest" required>
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label for="message">Anything we should know?</label>
<textarea id="message" name="message" rows="4"></textarea>
<p class="error" data-error-for="message" hidden></p>
<input type="hidden" name="page" value="pricing" />
<input type="hidden" name="_redirect" value="https://example.com/thanks" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Talk to sales</button>
</form>O script mantém o visitante na página. Ele mostra o erro de cada campo vindo do servidor embaixo desse campo e troca o formulário por uma frase que diz quem responde e quando.
const form = document.getElementById("lead-form");
function showErrors(fields) {
form.querySelectorAll("[data-error-for]").forEach((slot) => {
const message = fields[slot.dataset.errorFor]?.[0];
slot.textContent = message ?? "";
slot.hidden = !message;
});
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector("button");
button.disabled = true;
button.textContent = "Sending";
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) {
const thanks = document.createElement("p");
thanks.setAttribute("role", "status");
thanks.textContent = `Thanks, ${form.elements.name.value.trim().split(" ")[0]}. A person on our team will reply to ${form.elements.email.value} within one working day.`;
form.replaceWith(thanks);
return;
}
showErrors(result.error.fields ?? {});
if (!result.error.fields) alert(result.error.message);
} catch {
alert("Something went wrong. Email sales@example.com and we'll pick it up.");
}
button.disabled = false;
button.textContent = "Talk to sales";
});A server action encaminha o lead com a sua chave secreta e devolve o primeiro erro de cada campo, junto com os valores que o visitante digitou, para nada se perder num 422.
"use server";
import { headers } from "next/headers";
export interface LeadState {
sent: boolean;
firstName?: string;
errors: Record<string, string>;
values: Record<string, string>;
}
const KEYS = ["name", "email", "company", "interest", "message", "page", "_gotcha"] as const;
export async function sendLead(_previous: LeadState, formData: FormData): Promise<LeadState> {
const values = Object.fromEntries(KEYS.map((key) => [key, String(formData.get(key) ?? "").trim()]));
const origin = (await 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 ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(values),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { sent: true, firstName: values.name.split(" ")[0], errors: {}, values: {} };
const fields: Record<string, string[]> = result?.error?.fields ?? {};
const errors = Object.fromEntries(Object.entries(fields).map(([key, messages]) => [key, messages[0] ?? ""]));
if (!Object.keys(errors).length) errors.form = result?.error?.message ?? "Something went wrong. Email sales@example.com and we'll pick it up.";
return { sent: false, errors, values };
}"use client";
import { useActionState } from "react";
import { type LeadState, sendLead } from "./actions";
const initial: LeadState = { sent: false, errors: {}, values: {} };
export function LeadForm() {
const [state, action, pending] = useActionState(sendLead, initial);
const { errors, values } = state;
if (state.sent) {
return <p role="status">Thanks, {state.firstName}. A person on our team will reply within one working day.</p>;
}
return (
<form action={action}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required defaultValue={values.name} />
{errors.name ? <p role="alert">{errors.name}</p> : null}
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required defaultValue={values.email} />
{errors.email ? <p role="alert">{errors.email}</p> : null}
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" defaultValue={values.company} />
<label htmlFor="interest">What can we help with?</label>
<select id="interest" name="interest" defaultValue={values.interest ?? "pricing"}>
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label htmlFor="message">Anything we should know?</label>
<textarea id="message" name="message" rows={4} defaultValue={values.message} />
{errors.message ? <p role="alert">{errors.message}</p> : null}
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{errors.form ? <p role="alert">{errors.form}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</form>
);
}Quer ir além com Next.js? Leia Crie um formulário de leads com qualificação no Next.js.
O Zod confere os campos antes que qualquer coisa saia do seu servidor, e isInputError dá as mensagens dele por campo. A página precisa ser renderizada sob demanda para o formulário enviar para a action.
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
sendLead: defineAction({
accept: "form",
input: z.object({
name: z.string().trim().min(1, "Tell us who to reply to."),
email: z.email("Enter a work email we can reply to."),
company: z.string().trim().optional(),
interest: z.enum(["pricing", "demo", "custom", "other"]),
message: z.string().trim().max(5000).optional(),
page: z.string().default("pricing"),
_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.message });
return { firstName: input.name.split(" ")[0] };
},
}),
};---
import { actions, isInputError } from "astro:actions";
export const prerender = false;
const result = Astro.getActionResult(actions.sendLead);
const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
---
{result?.data ? (
<p role="status">Thanks, {result.data.firstName}. A person on our team will reply within one working day.</p>
) : (
<form method="POST" action={actions.sendLead}>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
{fields.name && <p role="alert">{fields.name[0]}</p>}
<label for="email">Work email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
{fields.email && <p role="alert">{fields.email[0]}</p>}
<label for="company">Company</label>
<input id="company" name="company" autocomplete="organization" />
<label for="interest">What can we help with?</label>
<select id="interest" name="interest">
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label for="message">Anything we should know?</label>
<textarea id="message" name="message" rows="4"></textarea>
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
<button>Talk to sales</button>
</form>
)}Quer ir além com Astro? Leia Crie um formulário de leads com qualificação no Astro.
A server function lê o FormData em .validator(), encaminha e devolve o primeiro nome ou um mapa de erros de campo que a rota renderiza embaixo de cada input.
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";
type Result = { ok: true; firstName: string } | { ok: false; errors: Record<string, string> };
const sendLead = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
const read = (key: string) => String(data.get(key) ?? "").trim();
return {
name: read("name"),
email: read("email"),
company: read("company"),
interest: read("interest"),
message: read("message"),
page: read("page"),
_gotcha: read("_gotcha"),
};
})
.handler(async ({ data }): Promise<Result> => {
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, firstName: data.name.split(" ")[0] ?? "" };
const fields: Record<string, string[]> = result.error.fields ?? { form: [result.error.message] };
return { ok: false, errors: Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0] ?? ""])) };
});
export const Route = createFileRoute("/contact-sales")({ component: ContactSales });
function ContactSales() {
const send = useServerFn(sendLead);
const [result, setResult] = useState<Result | null>(null);
const [pending, setPending] = useState(false);
const errors = result && !result.ok ? result.errors : {};
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
try {
setResult(await send({ data: new FormData(event.currentTarget) }));
} catch {
setResult({ ok: false, errors: { form: "Something went wrong. Email sales@example.com and we'll pick it up." } });
} finally {
setPending(false);
}
}
if (result?.ok) {
return <p role="status">Thanks, {result.firstName}. A person on our team will reply within one working day.</p>;
}
return (
<form method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{errors.name ? <p role="alert">{errors.name}</p> : null}
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{errors.email ? <p role="alert">{errors.email}</p> : null}
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" />
<label htmlFor="interest">What can we help with?</label>
<select id="interest" name="interest">
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label htmlFor="message">Anything we should know?</label>
<textarea id="message" name="message" rows={4} />
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{errors.form ? <p role="alert">{errors.form}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</form>
);
}Quer ir além com TanStack Start? Leia Crie um formulário de leads com qualificação no TanStack Start.
A action da rota encaminha o envio e devolve os erros de campo com o status code do Ikiform através de data(). O componente os lê em actionData.
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/contact-sales";
const KEYS = ["name", "email", "company", "interest", "message", "page", "_gotcha"];
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const values = Object.fromEntries(KEYS.map((key) => [key, String(formData.get(key) ?? "").trim()]));
const origin = 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 ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(values),
});
const result = await response.json();
if (result.ok) return { sent: true as const, firstName: values.name.split(" ")[0] };
const fields: Record<string, string[]> = result.error.fields ?? { form: [result.error.message] };
return data(
{ sent: false as const, errors: Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0]])) },
{ status: response.status },
);
}
export default function ContactSales({ actionData }: Route.ComponentProps) {
const navigation = useNavigation();
const pending = navigation.state === "submitting";
if (actionData?.sent) {
return <p role="status">Thanks, {actionData.firstName}. A person on our team will reply within one working day.</p>;
}
const errors: Record<string, string | undefined> = actionData?.errors ?? {};
return (
<Form method="post">
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{errors.name ? <p role="alert">{errors.name}</p> : null}
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{errors.email ? <p role="alert">{errors.email}</p> : null}
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" />
<label htmlFor="interest">What can we help with?</label>
<select id="interest" name="interest">
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label htmlFor="message">Anything we should know?</label>
<textarea id="message" name="message" rows={4} />
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{errors.form ? <p role="alert">{errors.form}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</Form>
);
}Quer ir além com React Router? Leia Crie um formulário de leads com qualificação no React Router.
A form action devolve fail() com os valores e os erros, e update({ reset: false }) mantém na tela o que o visitante digitou enquanto ele corrige um campo. 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";
const KEYS = ["name", "email", "company", "interest", "message", "page", "_gotcha"];
export const actions = {
default: async ({ request, fetch, url }) => {
const data = await request.formData();
const values = Object.fromEntries(KEYS.map((key) => [key, String(data.get(key) ?? "").trim()]));
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(values),
});
const result = await response.json();
if (!result.ok) {
const fields: Record<string, string[]> = result.error.fields ?? { form: [result.error.message] };
const errors = Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0]]));
return fail(response.status, { values, errors });
}
return { firstName: values.name.split(" ")[0] };
},
} satisfies Actions;<script lang="ts">
import { enhance } from "$app/forms";
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
let pending = $state(false);
const errors = $derived<Record<string, string | undefined>>(form?.errors ?? {});
</script>
{#if form?.firstName}
<p role="status">Thanks, {form.firstName}. A person on our team will reply within one working day.</p>
{:else}
<form
method="POST"
use:enhance={() => {
pending = true;
return async ({ update }) => {
await update({ reset: false });
pending = false;
};
}}
>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required value={form?.values?.name ?? ""} />
{#if errors.name}<p role="alert">{errors.name}</p>{/if}
<label for="email">Work email</label>
<input id="email" name="email" type="email" autocomplete="email" required value={form?.values?.email ?? ""} />
{#if errors.email}<p role="alert">{errors.email}</p>{/if}
<label for="company">Company</label>
<input id="company" name="company" autocomplete="organization" value={form?.values?.company ?? ""} />
<label for="interest">What can we help with?</label>
<select id="interest" name="interest" value={form?.values?.interest ?? "pricing"}>
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label for="message">Anything we should know?</label>
<textarea id="message" name="message" rows="4">{form?.values?.message ?? ""}</textarea>
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if errors.form}<p role="alert">{errors.form}</p>{/if}
<button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</form>
{/if}Quer ir além com SvelteKit? Leia Crie um formulário de leads com qualificação no SvelteKit.
Uma server route mantém a chave fora do cliente e repassa os erros de campo do Ikiform no data do erro. Defina NUXT_IKIFORM_SECRET_KEY no ambiente.
export default defineEventHandler(async (event) => {
const body = await readBody<Record<string, string | undefined>>(event);
const config = useRuntimeConfig(event);
const origin = getRequestHeader(event, "origin");
const keys = ["name", "email", "company", "interest", "message", "page", "_gotcha"];
const values = Object.fromEntries(keys.map((key) => [key, String(body[key] ?? "").trim()]));
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(values),
});
const result = await response.json();
if (!result.ok) {
throw createError({
status: response.status,
message: result.error.message,
data: { fields: result.error.fields ?? {} },
});
}
return { firstName: values.name?.split(" ")[0] };
});export default defineNuxtConfig({
runtimeConfig: {
ikiformSecretKey: "",
},
});<script setup lang="ts">
const lead = reactive({ name: "", email: "", company: "", interest: "pricing", message: "", _gotcha: "" });
const errors = ref<Record<string, string[]>>({});
const failure = ref("");
const sending = ref(false);
const firstName = ref("");
async function send() {
sending.value = true;
errors.value = {};
failure.value = "";
try {
const result = await $fetch("/api/lead", { method: "POST", body: { ...lead, page: "pricing" } });
firstName.value = result.firstName ?? "there";
} catch (error) {
const data = (error as { data?: { message?: string; data?: { fields?: Record<string, string[]> } } }).data;
errors.value = data?.data?.fields ?? {};
if (!Object.keys(errors.value).length) failure.value = data?.message ?? "Something went wrong. Email sales@example.com and we'll pick it up.";
} finally {
sending.value = false;
}
}
</script>
<template>
<p v-if="firstName" role="status">Thanks, {{ firstName }}. A person on our team will reply within one working day.</p>
<form v-else @submit.prevent="send">
<label for="name">Name</label>
<input id="name" v-model="lead.name" name="name" autocomplete="name" required />
<p v-if="errors.name" role="alert">{{ errors.name[0] }}</p>
<label for="email">Work email</label>
<input id="email" v-model="lead.email" name="email" type="email" autocomplete="email" required />
<p v-if="errors.email" role="alert">{{ errors.email[0] }}</p>
<label for="company">Company</label>
<input id="company" v-model="lead.company" name="company" autocomplete="organization" />
<label for="interest">What can we help with?</label>
<select id="interest" v-model="lead.interest" name="interest">
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label for="message">Anything we should know?</label>
<textarea id="message" v-model="lead.message" name="message" rows="4" />
<input v-model="lead._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="failure" role="alert">{{ failure }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Talk to sales" }}</button>
</form>
</template>Quer ir além com Nuxt? Leia Crie um formulário de leads com qualificação no Nuxt.
Sem um servidor próprio, o formulário envia direto ao Ikiform a partir do navegador. Mantenha chaves secretas fora deste 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 LeadForm() {
const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
const [errors, setErrors] = useState<Record<string, string[]>>({});
const [firstName, setFirstName] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = new FormData(event.currentTarget);
setStatus("sending");
setErrors({});
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
setFirstName(String(body.get("name")).trim().split(" ")[0] ?? "");
setStatus("sent");
return;
}
setErrors(result.error.fields ?? { form: [result.error.message] });
} catch {
setErrors({ form: ["Something went wrong. Email sales@example.com and we'll pick it up."] });
}
setStatus("idle");
}
if (status === "sent") {
return <p role="status">Thanks, {firstName}. A person on our team will reply within one working day.</p>;
}
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{errors.name ? <p role="alert">{errors.name[0]}</p> : null}
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{errors.email ? <p role="alert">{errors.email[0]}</p> : null}
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" />
<label htmlFor="interest">What can we help with?</label>
<select id="interest" name="interest">
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label htmlFor="message">Anything we should know?</label>
<textarea id="message" name="message" rows={4} />
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{errors.form ? <p role="alert">{errors.form[0]}</p> : null}
<button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Talk to sales"}</button>
</form>
);
}O mesmo envio pelo navegador como um componente Vue. O formulário mantém o action, então um visitante cujo script não carregar ainda consegue falar com você.
<script setup lang="ts">
import { ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const status = ref<"idle" | "sending" | "sent">("idle");
const errors = ref<Record<string, string[]>>({});
const firstName = ref("");
async function send(event: SubmitEvent) {
const body = new FormData(event.target as HTMLFormElement);
status.value = "sending";
errors.value = {};
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
firstName.value = String(body.get("name")).trim().split(" ")[0] ?? "";
status.value = "sent";
return;
}
errors.value = result.error.fields ?? { form: [result.error.message] };
} catch {
errors.value = { form: ["Something went wrong. Email sales@example.com and we'll pick it up."] };
}
status.value = "idle";
}
</script>
<template>
<p v-if="status === 'sent'" role="status">Thanks, {{ firstName }}. A person on our team will reply within one working day.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
<p v-if="errors.name" role="alert">{{ errors.name[0] }}</p>
<label for="email">Work email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<p v-if="errors.email" role="alert">{{ errors.email[0] }}</p>
<label for="company">Company</label>
<input id="company" name="company" autocomplete="organization" />
<label for="interest">What can we help with?</label>
<select id="interest" name="interest">
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label for="message">Anything we should know?</label>
<textarea id="message" name="message" rows="4" />
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="errors.form" role="alert">{{ errors.form[0] }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Talk to sales" }}</button>
</form>
</template>- Troque
frm_xxxxxxxxxxxxxxxxpelo id do seu formulário, que está no painel. - O input oculto
_gotchaé o honeypot. As pessoas nunca o veem; bots que preenchem todos os inputs se marcam como spam. - O input oculto
pageregistra de qual página o lead veio: preços, uma página de recurso, uma landing page de campanha. Se você definir campos, adicionepagecomo um campo de texto opcional, ou o envio é recusado comUnknown field. - As versões no servidor repassam o header
Origindo visitante, porque um fetch feito no servidor não envia nenhum e o Ikiform detecta por ele os testes gratuitos delocalhost. - O formulário em HTML simples precisa de Redirect after submit, ou de um input
_redirectem um dos seus Allowed domains, para cair na sua própria página de agradecimento.
IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.A documentação cobre a configuração de cada framework em detalhes: Next.js, Astro, SvelteKit, Nuxt, React, Vue, envio a partir de um servidor e HTML simples.
Fale com o lead em minutos
O formulário é a parte fácil. O que separa um formulário de leads que vende de um que só coleta é o tempo entre o envio e a resposta de uma pessoa. Veja como é esse caminho quando funciona:
- O lead envia o formulário.
- O Ikiform armazena e responde ao navegador em bem menos de um segundo.
- O e-mail de notificação e o webhook saem de uma fila segundos depois.
- Alguém da equipe clica em Reply na caixa de entrada, ou no link mailto no chat, e a resposta vai para o lead.
Responda pela sua caixa de entrada
Cada e-mail de notificação define o endereço de reply-to como o do lead. O Ikiform o pega primeiro de um campo _replyto e depois de um campo chamado email, email_address, your_email, reply_to, contact_email. Mantenha o id do campo email e o Reply vai para o lead, não para o Ikiform. O assunto é New submission: mais o nome do formulário, então dê ao formulário o nome do que ele é, como Talk to sales (fale com vendas), e filtre por esse assunto para marcar ou destacar os leads.
Adicione até 10 destinatários por formulário nas configurações de notificação do formulário: membros do workspace, ou endereços externos que confirmam por e-mail antes. Mande para as pessoas que respondem, não para um alias compartilhado sem dono: um lead em cinco caixas de entrada e assumido por ninguém espera mais do que um lead em uma só.
Avise a equipe onde ela está
O e-mail funciona quando alguém é dono da caixa de entrada. Uma equipe que vive no chat responde mais rápido quando o lead aparece lá. Adicione um webhook para Slack, Teams ou Discord seguindo Slack, Microsoft Teams ou Discord. Coloque um link mailto: na mensagem para que quem assumir o lead possa responder com um clique.
Roteie pelo que a pessoa pediu
Como interest só aceita valores listados, um pequeno relay pode se ramificar com base nele com segurança. Esta rota manda pedidos de demonstração e perguntas sobre contrato para canais próprios e todo o resto para o canal principal de vendas:
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
const CHANNELS: Record<string, string | undefined> = {
demo: process.env.SLACK_DEMO_WEBHOOK,
custom: process.env.SLACK_ENTERPRISE_WEBHOOK,
};
export async function POST(request: Request) {
const payload = await request.text();
let event;
try {
event = await verifyWebhook({ payload, headers: request.headers, secret: process.env.IKIFORM_WEBHOOK_SECRET! });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
if (event.type !== "submission.created") return new Response(null, { status: 204 });
const lead = event.data.fields;
const interest = String(lead.interest ?? "other");
const target = CHANNELS[interest] ?? process.env.SLACK_SALES_WEBHOOK!;
const text = [
`*${lead.name}* from ${lead.company || "an unknown company"} wants: ${interest}`,
`Reply: mailto:${lead.email}`,
lead.message ? `> ${String(lead.message).slice(0, 300)}` : "",
]
.filter(Boolean)
.join("\n");
const response = await fetch(target, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text }),
});
if (!response.ok) return new Response(`Slack answered ${response.status}`, { status: 502 });
return new Response(null, { status: 204 });
}verifyWebhookconfere a assinatura e o timestamp, então ninguém mais consegue publicar leads falsos nos seus canais.- Um
502quando o Slack está fora do ar faz o Ikiform tentar de novo a mesma entrega, com o mesmowebhook-id, por cerca de 3 dias. - Mude o rótulo de uma opção à vontade. Mantenha o valor estável depois que um relay ou uma automação depender dele.
Mantenha o lixo fora do pipeline
Formulários de leads atraem um tipo específico de spam: gente vendendo SEO, link building e desenvolvimento terceirizado para você. Isso desperdiça a sua resposta mais rápida, então filtre antes que chegue a alguém.
| Defesa | O que ela barra | Configuração |
|---|---|---|
| Honeypot | Bots que preenchem todos os inputs | O input oculto _gotcha |
| Pontuação de spam | Frases conhecidas de pitch de vendas como seo services (+2), links no nome (+3), três ou mais links (+2), repetições (+3). Com 5, é spam | Nenhuma |
| Limite de taxa | Scripts que enviam em loop | Configure o formulário como Strict, 2 a cada 10 minutos por visitante. Um lead real raramente envia duas vezes em dez minutos |
| Domínios permitidos | Envios de outros sites | Adicione seu domínio em Allowed domains |
| Verificação humana | Bots num navegador de verdade | Defina Spam check como Ikiform. As versões com fetch passam a precisar de um token de challenge() |
Spam recebe a mesma resposta de sucesso de um lead real, não envia e-mail nem webhook e não conta no uso. Isso também significa que um lead julgado errado fica em silêncio. Confira o filtro Spam uma vez por semana; marcar um envio como não spam dispara o e-mail e os webhooks dele nesse momento. A documentação de spam lista todos os sinais.
Saiba de onde vêm os leads
Cada envio já registra a origem e o referrer da página, que aparecem no webhook como meta.origin e meta.referrer. As tags de campanha precisam de uma ajudinha, porque o visitante muitas vezes chega numa página e envia em outra. Este snippet copia os parâmetros utm_ para inputs ocultos e os guarda durante a sessão:
<input type="hidden" name="utm_source" />
<input type="hidden" name="utm_medium" />
<input type="hidden" name="utm_campaign" />
<script>
const params = new URLSearchParams(location.search);
for (const key of ["utm_source", "utm_medium", "utm_campaign"]) {
let value = params.get(key);
try {
if (value) sessionStorage.setItem(key, value);
else value = sessionStorage.getItem(key);
} catch {}
const input = document.querySelector(`#lead-form [name="${key}"]`);
if (input && value) input.value = value.slice(0, 200);
}
</script>Num framework, leia os mesmos parâmetros da URL no servidor e renderize-os como inputs ocultos. Com campos definidos, adicione cada id utm_ como um campo de texto opcional. Nomes que começam com _ nunca são armazenados, então não os use para dados que você quer guardar.
O que o lead vê em seguida
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. Para um formulário de leads, isso muitas vezes não é problema: uma resposta pessoal rápida vale mais que uma automática. Se mesmo assim você quiser uma confirmação instantânea, envie pelo seu CRM ou ferramenta de e-mail quando o webhook criar o contato, para que respostas e descadastros fiquem registrados num só lugar.
Seja o que for que você envie, a mensagem de sucesso na página é a promessa. Diga quem responde e até quando, e cumpra. Um formulário que diz um dia útil e responde em dez minutos ganha confiança; um que diz dez minutos e responde no dia seguinte a perde.
Leads no seu CRM
Não há integrações de um clique. Toda ferramenta é alcançada por um webhook assinado, diretamente ou por uma ferramenta de automação como Zapier ou n8n. Para um CRM, isso significa um relay que mapeia o corpo fixo do webhook do Ikiform para a chamada de criação de contato do CRM. Leads no HubSpot ou no Pipedrive mostra os dois passo a passo, incluindo duplicados e novas tentativas. Até você precisar de um CRM, o painel e uma exportação CSV semanal são um ótimo pipeline.
Escolha o seu caso
A configuração acima serve para qualquer lead. Estas páginas cobrem o que muda para um tipo específico de lead e para levar os leads a um CRM.
- Formulário de pedido de demonstraçãoUm formulário de agendar demonstração que armazena o lead e depois manda o visitante para o Cal.com ou o Calendly com nome e e-mail já preenchidos.
- Formulário de pedido de orçamentoUm formulário de pedido de orçamento que recebe briefings como links compartilhados em vez de uploads, coloca um prazo em cada pedido e mantém leads cheios de links fora do spam.
- Formulário de pedido de consultaUm formulário de pedido de consulta para contadores, advogados, coaches e clínicas que pergunta como e quando ligar, e deixa os detalhes sensíveis para a conversa.
- Formulário de consulta de projetoUm formulário de "comece um projeto" para estúdios e freelancers que separa as consultas em quentes, mornas e sem fit por orçamento e prazo antes de alguém ler o briefing.
- Leads no HubSpot ou no PipedriveMapeie cada webhook assinado do Ikiform para o upsert de contatos do HubSpot ou para as chamadas de pessoa e lead do Pipedrive, sem contatos duplicados quando uma entrega é repetida.
- Formulário de leads em Next.jsUm formulário de leads no App Router do Next.js que valida com Zod numa server action, guarda as UTMs do primeiro toque num cookie e manda leads qualificados para a sua agenda.
- Formulário de leads em AstroUm formulário de leads em Astro feito com Actions: validação com Zod, UTMs de primeiro toque num site estático, uma página de contato sob demanda e redirecionamento ao seu agendador.
- Formulário de leads em TanStack StartUm formulário de leads em TanStack Start: middleware de requisição global para UTMs de primeiro toque, uma server function com Zod e um redirect lançado para o seu agendador.
- Formulário de leads em React RouterUm formulário de leads em React Router no framework mode: middleware na raiz com um cookie de primeiro toque assinado, uma action de rota que valida e um redirecionamento ao seu agendador.
- Formulário de leads em SvelteKitUm formulário de leads em SvelteKit: o hook handle registra as UTMs de primeiro toque, uma form action valida com Valibot e redirect() manda leads qualificados para uma agenda.
- Formulário de leads em NuxtUm formulário de leads em Nuxt: um server middleware registra as UTMs de primeiro toque, uma server route validada com Zod atende fetch e envios simples, e os bons leads agendam uma call.
Confira se funciona
Siga estes passos antes de mandar tráfego para 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. O assunto é
New submission:mais o nome do formulário, e o Reply abre uma mensagem para o endereço que você digitou. - 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.
- Cronometre: envie pelo celular e anote quanto tempo levam para chegar o e-mail e a mensagem no chat.
Solução de problemas
| Problema | O que fazer |
|---|---|
| O Reply vai para o endereço do Ikiform, não para o lead | O formulário não tem nenhum campo que o Ikiform reconheça como o e-mail do lead. Renomeie o campo para email, ou envie um campo _replyto com o endereço. Nomes aceitos: email, email_address, your_email, reply_to, contact_email. |
422 com Unknown field para page ou utm_source | O formulário tem uma definição de campos e o input oculto não está nela. Adicione-o como um campo de texto opcional, ou remova o input. |
422 validation_failed | O envio não corresponde à definição de campos do formulário. error.fields aponta cada campo e o motivo. Faça o name do input bater com o id do campo. |
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. |
| Um destinatário não recebe e-mails de notificação | Um endereço de fora do workspace precisa confirmar antes, pelo e-mail que o Ikiform envia a ele. Verifique se ele foi confirmado e depois confira a pasta de spam do destinatário. |
Perguntas
Um formulário no seu site que coleta os dados de contato de um potencial cliente e o que ele quer, para a sua equipe dar sequência. Ele difere de um formulário de contato comum porque qualifica o lead, geralmente com uma pergunta de escolha fixa, e é configurado para chegar rápido a vendas.
Aponte o formulário para o seu endpoint do Ikiform e mantenha os e-mails de notificação ligados para o formulário. Cada lead chega em segundos, para até 10 destinatários, e clicar em Reply escreve para o lead, desde que o campo de e-mail se chame email ou o formulário envie _replyto.
O mínimo que permita uma boa resposta: geralmente nome, e-mail, uma pergunta de qualificação e uma mensagem opcional. Adicione telefone, orçamento ou tamanho da equipe só quando a resposta mudar o que a sua equipe faz em seguida.
Por webhook, sim. Não há integrações de um clique, então um pequeno relay ou uma ferramenta de automação como Zapier, Make ou n8n mapeia cada webhook assinado para a API do CRM e cria o contato ou o lead.
Não do Ikiform. O Ikiform envia e-mail para a sua equipe, não para quem preencheu o formulário. Envie qualquer confirmação pelo seu CRM ou ferramenta de e-mail, disparada pelo webhook, ou responda pessoalmente a partir do e-mail de notificação.
Não com uma configuração nativa. A opção de domínios permitidos do campo de e-mail é uma allowlist, que serve para formulários internos, não públicos. Aceite todos os endereços e separe os leads de e-mail gratuito no seu relay ou CRM; muitos compradores reais usam um endereço pessoal.
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.